Skip to content
📖 The Auto Programming LanguageBook contents

Enums & Pattern Matching ​

In Chapter 6, you learned about type — Auto's way of defining structured data with named fields. A Rectangle always has a width and a height. A User always has a name and an age. But what if a value could be one of several different shapes? What if a shape is either a circle or a rectangle, but never both? That's where enumerations — or enums — come in.

Enums let you define a type by listing its possible variants. Each variant can carry its own data, and each variant can be handled independently with pattern matching. In this chapter, you'll learn how to define enums, attach data to their variants, destructure them with is, handle them with on blocks, and refine patterns with guards.

In this chapter, we'll cover:

  • What Are Enums? — defining types as a set of named variants
  • Enums with Data — carrying values inside each variant
  • Pattern Matching with is — destructuring enums into their parts
  • on Blocks — message-handler style matching
  • Destructuring Types — struct destructuring with field names
  • Exhaustive Matching — the compiler ensures you handle every case
  • Guards — conditional patterns with if

What Are Enums? ​

Where a type (struct) says "this value has fields X, Y, and Z," an enum says "this value is one of A, B, or C." You use an enum when a value can be different things, and you need to handle each possibility differently.

A direction is a classic example. A direction is North, South, East, or West — nothing else. We can enumerate every possibility, which is where the term enumeration comes from.

Defining a simple `Direction` enum

This prints:

North

Direction is now a custom type. You create a value with Direction.North — the enum name, a dot, and the variant name. This is different from Rust, which uses Direction::North with a double colon.

Each target language maps enums differently:

  • Rust uses the same enum keyword. Variants are accessed with ::. Auto automatically generates a Display implementation so you can print the variant name directly.
  • Python uses the Enum class from the standard library. Each variant becomes a class attribute with an integer value.
  • C uses a typedef enum with integer discriminants. A helper function converts the enum value to a printable string.
  • TypeScript uses the enum keyword. Direction[d] converts the numeric value back to its name at runtime.

Simple enums like Direction — where each variant is just a name with no attached data — are useful for representing a fixed set of states. But enums become truly powerful when each variant can carry its own data.

Enums with Data ​

Each enum variant can hold data. A Circle variant might carry a radius. A Rect variant might carry a width and a height. The key insight is that each variant can carry different types and different amounts of data.

An enum whose variants carry data

This prints:

Circle(5.0)
Rect(3.0, 4.0)

The syntax for data variants is VariantName(Type) — the variant name followed by parenthesized types. To construct a value, you write Shape.Circle(5.0) — the enum name, a dot, the variant name, and the data in parentheses.

Notice how each language handles data-carrying variants:

  • Rust uses the same Variant(Type) syntax natively. Auto's syntax maps directly.
  • Python uses a tagged union approach — each variant becomes a @dataclass, and the enum type is a Union of all variant classes.
  • C uses a tagged union pattern — a struct with a tag enum and a union containing each variant's data. This is the manual equivalent of what Auto and Rust do automatically.
  • TypeScript uses a discriminated union — each variant is a class, and the enum type is a union type Circle | Rect.

The C and Python implementations show what enums look like "under the hood" — a tag that identifies which variant it is, plus the data for that variant. Auto and Rust handle this automatically, so you don't have to think about it.

Pattern Matching with is ​

Having data inside enum variants is only half the story. The other half is getting the data out. That's what pattern matching does. Auto uses the is keyword to match a value against a series of patterns and execute different code for each one.

Destructuring enum variants with `is`

This prints:

78.53975
12

Let's break down the is expression:

  • is s { ... } inspects the value s and tries each pattern in order.
  • Circle(r) is a pattern that matches the Circle variant and binds its inner value to the variable r. When s is Shape.Circle(5.0), this pattern matches and r becomes 5.0.
  • Rect(w, h) matches the Rect variant and binds the width to w and the height to h.
  • Each arm has the form pattern => expression. The expression on the right becomes the return value of the entire is block.

The is keyword is Auto's equivalent of Rust's match. Here's how the pattern matching maps across languages:

LanguageConstructExample
Autois value { Pattern => expr }Circle(r) => r * r
Rustmatch value { Pattern => expr, }Shape::Circle(r) => r * r,
Pythonif isinstance(v, T)isinstance(s, Circle)
Cswitch (tag)case ShapeTag_Circle:
TypeScriptv instanceof Ts instanceof Circle

In Rust, Auto's is maps directly to match — the arms are the same, except Rust requires commas between arms. In Python and TypeScript, pattern matching becomes isinstance / instanceof checks. In C, it becomes a switch on the tag field.

on Blocks ​

Auto provides a second pattern matching construct called on. The on block has the same semantics as is but uses a message-handler style. It's especially readable when each arm calls a function or performs a side effect like printing.

Message-handler style matching with `on`

This prints:

Goodbye!
Moving to 10, 20
Hello!

The on block is syntactically identical to is — both use the same pattern syntax, both bind variables, and both are exhaustive. The difference is stylistic. Use is when you're computing a value (the last expression in each arm becomes the return value). Use on when you're performing actions for each case (like printing, logging, or dispatching to a handler).

Think of it this way: is asks "what is this?" and returns an answer. on says "handle this" and performs an action.

In all target languages, both is and on compile to the same construct: match in Rust, if/elif chains in Python, switch in C, and if/else if chains with instanceof in TypeScript.

Destructuring Types ​

Pattern matching isn't limited to enums. You can also destructure struct-like types by matching on their field names. This is useful when you want to extract values from a type, transform them, and create a new instance.

Destructuring and rebuilding a struct

This prints:

2
1

The swap function takes a Pair and creates a new Pair with the fields swapped. The body of the function is a single expression — Pair { x: p.y, y: p.x } — which becomes the return value because it's the last expression in the function body.

This pattern is sometimes called "destructured construction" — you read fields from one instance and use them to construct a new instance. It works the same way in all target languages. Rust's syntax is identical to Auto's. Python uses keyword arguments. C assigns fields individually. TypeScript passes the values to the constructor.

Note that p is passed by value, so the original pair is unchanged. This is the default in Auto (and Rust and C). The swap function creates a new Pair rather than modifying the original.

Exhaustive Matching ​

Both is and on are exhaustive — the compiler ensures you handle every possible variant. If you add a new variant to an enum and forget to update a match, the compiler will refuse to compile your program. This is one of the biggest safety advantages of enums over, say, integer constants.

Exhaustive matching — every variant is handled

This prints:

red
green

Exhaustiveness is a compile-time guarantee in Auto and Rust. If you were to add a Yellow variant to Color but forget to handle it in color_name, the compiler would produce an error. Python and TypeScript don't enforce this at compile time — the if/elif chain simply falls through without matching. C's switch requires a default case to avoid warnings, which is why the C output includes default: return "unknown";.

This guarantee is particularly valuable in large codebases. When you add a new variant to an enum, the compiler tells you every place that needs updating. You never miss a case.

Guards ​

Sometimes a pattern alone isn't specific enough. You might want to match a variant and check a condition on its data. Auto supports guards — if conditions attached to pattern arms that add an extra layer of filtering.

Pattern matching with guard conditions

This prints:

positive integer
negative integer
zero
float: 3.14
string: hello

Guards add a condition after the pattern, separated by if:

  • Int(n) if n > 0 matches the Int variant and requires that n is positive. If the value is Int(42), this arm matches. If the value is Int(-1), the guard fails and Auto tries the next arm.
  • Int(n) if n < 0 matches the Int variant with a negative value.
  • Int(_) uses the wildcard pattern _ to match any Int value. The _ means "I don't care about this value." This arm catches Int(0) — the only remaining case.

Arms are tried in order, top to bottom. The first arm whose pattern matches and whose guard is true wins. This is important: if you put Int(_) before the guarded arms, it would match everything and the guards would never be checked.

In the target languages, guards become nested if statements inside each case branch. In Rust, the guard syntax if condition is native to match. In C, the guard becomes an if inside the case block. In Python and TypeScript, the guard becomes a nested conditional after the isinstance check.

Summary ​

Enums and pattern matching give you a way to model data that can be one of several different shapes, and to handle each shape safely and explicitly. Here's what you learned in this chapter:

  • Simple enums — enum Direction { North, South, East, West } — define a type as a fixed set of named variants. Access variants with dot notation: Direction.North.
  • Enums with data — Circle(float) and Rect(float, float) — each variant can carry its own data. Construct with Shape.Circle(5.0).
  • Pattern matching with is — is s { Circle(r) => ... } — destructures enum values and binds inner data to variables. The last expression in each arm becomes the return value.
  • on blocks — same semantics as is, but stylistically suited for performing actions (printing, dispatching) rather than computing values.
  • Destructuring types — read fields from a struct and use them to construct a new value: Pair { x: p.y, y: p.x }.
  • Exhaustive matching — the compiler ensures you handle every variant. If you add a new variant, every is and on that doesn't handle it becomes a compile error.
  • Guards — Int(n) if n > 0 adds a condition to a pattern arm. Arms are tried in order, and the first matching arm with a true guard wins.

Here's the mapping across all target languages:

ConceptAutoRustPythonCTypeScript
Define enumenum { ... }enum { ... }class(Enum)typedef enumenum { ... }
Access variantE.VE::VE.VE_VE.V
Data variantV(T)V(T)@dataclasstagged unionclass
Pattern matchis v { }match v { }isinstanceswitchinstanceof
GuardP if condP if condnested ifnested ifnested if
Exhaustivenesscompile-timecompile-timenonedefaultnone

Enums are the missing piece from Chapter 6. Combined with type (structs), you now have two complementary tools for modeling your domain: type for "this value is a thing with these fields," and enum for "this value is one of these things." Pattern matching with is and on ties it all together, letting you inspect and handle each possibility safely and concisely.

Released under the MIT License.