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 onBlocks — 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.
This prints:
NorthDirection 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
enumkeyword. Variants are accessed with::. Auto automatically generates aDisplayimplementation so you can print the variant name directly. - Python uses the
Enumclass from the standard library. Each variant becomes a class attribute with an integer value. - C uses a
typedef enumwith integer discriminants. A helper function converts the enum value to a printable string. - TypeScript uses the
enumkeyword.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.
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 aUnionof all variant classes. - C uses a tagged union pattern — a
structwith a tag enum and aunioncontaining 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 typeCircle | 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.
This prints:
78.53975
12Let's break down the is expression:
is s { ... }inspects the valuesand tries each pattern in order.Circle(r)is a pattern that matches theCirclevariant and binds its inner value to the variabler. WhensisShape.Circle(5.0), this pattern matches andrbecomes5.0.Rect(w, h)matches theRectvariant and binds the width towand the height toh.- Each arm has the form
pattern => expression. The expression on the right becomes the return value of the entireisblock.
The is keyword is Auto's equivalent of Rust's match. Here's how the pattern matching maps across languages:
| Language | Construct | Example |
|---|---|---|
| Auto | is value { Pattern => expr } | Circle(r) => r * r |
| Rust | match value { Pattern => expr, } | Shape::Circle(r) => r * r, |
| Python | if isinstance(v, T) | isinstance(s, Circle) |
| C | switch (tag) | case ShapeTag_Circle: |
| TypeScript | v instanceof T | s 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.
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.
This prints:
2
1The 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.
This prints:
red
greenExhaustiveness 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.
This prints:
positive integer
negative integer
zero
float: 3.14
string: helloGuards add a condition after the pattern, separated by if:
Int(n) if n > 0matches theIntvariant and requires thatnis positive. If the value isInt(42), this arm matches. If the value isInt(-1), the guard fails and Auto tries the next arm.Int(n) if n < 0matches theIntvariant with a negative value.Int(_)uses the wildcard pattern_to match anyIntvalue. The_means "I don't care about this value." This arm catchesInt(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)andRect(float, float)— each variant can carry its own data. Construct withShape.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. onblocks — same semantics asis, 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
isandonthat doesn't handle it becomes a compile error. - Guards —
Int(n) if n > 0adds 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:
| Concept | Auto | Rust | Python | C | TypeScript |
|---|---|---|---|---|---|
| Define enum | enum { ... } | enum { ... } | class(Enum) | typedef enum | enum { ... } |
| Access variant | E.V | E::V | E.V | E_V | E.V |
| Data variant | V(T) | V(T) | @dataclass | tagged union | class |
| Pattern match | is v { } | match v { } | isinstance | switch | instanceof |
| Guard | P if cond | P if cond | nested if | nested if | nested if |
| Exhaustiveness | compile-time | compile-time | none | default | none |
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.