Lesson 1 of 5
Enums, Option and Pattern Matching
"Well-typed programs cannot go wrong." — Robin Milner, A Theory of Type Polymorphism in Programming (1978)
Part II was about rules: who owns a value, who may look at it, how long a reference may live. Those rules are what makes Rust unusual, but they are not what makes it pleasant to write, and a reader who has only met Part II would be forgiven for thinking the language is a long argument with a compiler. Part III is the other half — the machinery you actually build with — and it starts with the feature that does more work than any other in idiomatic Rust.
A Rust enum is not the enum you know from C, Java or TypeScript. Those are a set of named constants: a value is one of several labels, and the labels carry nothing. A Rust enum is a sum type — a value is exactly one of several variants, and each variant can carry its own data, of its own shape. One variant might hold two integers, another a single character, another nothing at all.
That difference sounds small and changes everything about how you model problems. A type can say "this is either a successful parse holding a number, or a failure holding an error message" and the compiler will not let you read the number without first establishing which one you have. States that cannot coexist become variants that cannot coexist, and the whole category of bug where you check a flag and then read the wrong field stops being expressible.
The companion feature is pattern matching, and the two are designed for each other. match is not a switch statement with better syntax; it destructures values, binds the pieces you name, tests conditions inside the pattern, and — the part that matters most — refuses to compile unless you have handled every possibility. That exhaustiveness check is what turns an enum from documentation into a guarantee.
Milner's line in the epigraph was about exactly this idea half a century ago: that a sufficiently expressive type system makes certain errors not merely detectable but unrepresentable. Rust is one of the first mainstream languages to put a full sum type and exhaustive matching in the hands of people writing web servers and command-line tools, and this chapter is about using them for everything they are worth.
If a value can be in several states that carry different data, what does a language gain by making those states separate variants rather than fields on one struct with a flag?
Enums that carry data
Start with the shape, because it is the thing that does not exist in most languages you have used.
// Each variant carries its OWN data — a tuple, named fields, or nothing.
#[derive(Debug)]
enum Event {
Click { x: i32, y: i32 },
KeyPress(char),
Scroll(i32),
Close,
}
fn describe(e: &Event) -> String {
match e {
Event::Click { x, y } => format!("click at ({x}, {y})"),
Event::KeyPress(c) => format!("key {c:?}"),
Event::Scroll(delta) => format!("scroll {delta:+}"),
Event::Close => String::from("window closed"),
}
}
fn main() {
let log = [
Event::Click { x: 12, y: 40 },
Event::KeyPress('q'),
Event::Scroll(-3),
Event::Close,
];
for e in &log { println!("{}", describe(e)); }
// The enum is as big as its largest variant plus a tag.
println!("size_of Event {}", std::mem::size_of::<Event>());
}click at (12, 40)
key 'q'
scroll -3
window closed
size_of Event 12
Four variants with four different shapes, held in one array, handled by one function. Click has named fields like a struct, KeyPress and Scroll hold a single value each like a tuple, and Close holds nothing.
Now consider the alternative you would have written in a language without sum types: a struct with an event_type field and nullable x, y, key and delta fields, where four of the six are meaningless at any given moment and nothing stops you reading key on a scroll event. That structure cannot express "these fields exist only together", so the invariant lives in comments and in the discipline of whoever edits it next.
The size tells you how it is represented. Twelve bytes: the largest variant is Click with two i32s, which is eight, plus four more for the discriminant and alignment. There is one tag saying which variant this is, and the payload shares the same space — the enum is as large as its biggest variant and no larger. That is why the compiler can refuse to let you read x without checking the tag first: at run time, the bytes where x would be might currently hold a char.
That is also the connection back to Part I's niche optimisation. When a variant's payload has an impossible bit pattern, the discriminant can hide inside it and the tag costs nothing — which is why Option<Box<T>> was the same size as Box<T>. Enums are not a costly abstraction layered on top; they are how Rust represents choice, and the representation is about as tight as one could design by hand.
Think about forms. A badly designed form has one page with every question on it — maiden name, company registration number, visa type — and a note saying "complete only the sections that apply to you". Readers fill in the wrong boxes constantly, and anyone processing the form has to know which combinations are meaningful. A well designed form asks one question first and then gives you a different page depending on the answer: the questions you are looking at are exactly the ones that apply, and the impossible combinations are not merely discouraged, they are absent. A struct with a flag and nullable fields is the first form. An enum is the second.
When a variant contains the enum itself
There is one place enums collide with Part I's layout rules, and the error it produces confuses people badly enough to be worth meeting deliberately.
Suppose you model an expression tree: a number, or the sum of two expressions. The natural first attempt declares a variant holding two more expressions directly — and the compiler rejects it with E0072, recursive type has infinite size, which sounds like a riddle until you remember how enums are laid out.
An enum occupies its largest variant plus a tag, and the compiler computes that size at compile time. If a variant contains two of the enum itself, its size is twice the enum's size plus a tag, which is an equation with no finite solution. The type does not have a size, so there is no amount of stack space the compiler could reserve for one.
The fix is indirection: Box the recursive fields. A Box<Expr> is one pointer, eight bytes, whatever it points at — so the variant's size becomes two pointers plus a tag, the equation terminates, and the tree lives on the heap with each node owning its children. That is the whole of it, and the compiler's suggested fix says so in as many words.
This is worth more than a workaround, because it exposes something true about the language. In a garbage-collected language every object is a reference and recursive types cost you no thought — which also means you never find out that you are paying for an indirection at every node. Rust makes you write the Box, so the pointer is visible in the type, and the cost is a decision rather than a default. It is the same honesty as clone: the expensive thing has a name at the point where it happens.
It also explains the shape of the standard library. Option<Box<T>> appears everywhere in linked structures precisely because this is how you say "either another node, or the end", and it costs exactly one pointer thanks to the niche optimisation from Part I. The Node with a RefCell<Option<Rc<Node>>> from the previous chapter is the same pattern with sharing added — and now you can read that type left to right and say what every layer is for.
A practical note: enums can have impl blocks like any other type, so the methods that interpret a tree — evaluating it, printing it, counting its nodes — belong on the enum itself rather than in free functions. A recursive method matching on self and calling itself on the boxed children is the idiomatic shape, and it reads remarkably close to the grammar it implements.
Option, and a language without null
Option<T> is an ordinary enum — Some(T) or None — defined in the standard library with no special compiler support. Everything about how Rust handles absence follows from that one fact.
fn parse_port(raw: &str) -> Option<u16> { raw.parse().ok() }
fn main() {
let good = parse_port("8080");
let bad = parse_port("eighty");
println!("good {good:?}");
println!("bad {bad:?}");
// Transform without unwrapping.
println!("doubled {:?}", good.map(|p| p * 2));
println!("bad doubled {:?}", bad.map(|p| p * 2));
// Supply a fallback.
println!("or default {}", bad.unwrap_or(80));
println!("or computed {}", bad.unwrap_or_else(|| 8000));
// Chain operations that may themselves fail.
let chained = parse_port("443").and_then(|p| if p < 1024 { Some(p) } else { None });
println!("privileged? {chained:?}");
// Treat it as a tiny collection.
println!("is_some/is_none {} {}", good.is_some(), bad.is_none());
println!("sum of somes {}", [good, bad, parse_port("1")].iter().flatten().sum::<u16>());
}good Some(8080)
bad None
doubled Some(16160)
bad doubled None
or default 80
or computed 8000
privileged? Some(443)
is_some/is_none true true
sum of somes 8081
The crucial property is that a u16 and an Option<u16> are different types. There is no null u16. A function that returns a port returns a port; a function that might fail to produce one says so in its signature, and the caller cannot accidentally use the result as a number. The absence is in the type system rather than lurking inside every value.
This is Rust's answer to the problem Tony Hoare called his billion-dollar mistake — the null reference he introduced in ALGOL W in 1965, which made every reference in dozens of subsequent languages secretly two things. The cost was not that null exists; it is that nothing in the type distinguishes a reference that might be null from one that cannot be, so every dereference is an unchecked assumption and the failure surfaces far from the mistake.
Note how little unwrapping appears in that block. map transforms the value if there is one and passes None through untouched. and_then chains an operation that may itself produce nothing, flattening what would otherwise become Option<Option<u16>>. unwrap_or and unwrap_or_else supply a fallback — the second taking a closure, so an expensive default is computed only when needed.
The last line is a small revelation for most people: flatten on an iterator of Options yields only the Some values, because Option is iterable as a collection of zero or one items. That unification is deliberate throughout the standard library, and it is why so much Option handling reads as ordinary data manipulation rather than null-checking.
Sum types are much older than Rust — ML had them in the 1970s, and Haskell's Maybe is the direct ancestor of Option — but for thirty years they stayed in languages with academic reputations, while the industry built on null. What changed was not theory but tooling and timing: Rust and Swift arrived within a year of each other, both with sum types and exhaustive matching, both aimed squarely at systems and application programmers rather than researchers. Swift's Optional and Rust's Option are the same idea reaching mainstream practice simultaneously, and a generation of programmers learned from them that null was a choice rather than a fact of computing. TypeScript's strict null checks and Kotlin's nullable types followed as retrofits to languages that could not remove null but could at least make it visible.
Exhaustiveness is the whole point
Everything above would be pleasant but not transformative without the check that makes it binding.
enum Status { Draft, Review, Published, Archived }
fn label(s: &Status) -> &'static str {
// Archived was added to the enum and this match was never updated.
match s {
Status::Draft => "draft",
Status::Review => "in review",
Status::Published => "live",
}
}
fn main() { println!("{}", label(&Status::Draft)); }error[E0004]: non-exhaustive patterns: `&Status::Archived` not covered
--> src/main.rs:6:11
|
6 | match s {
| ^ pattern `&Status::Archived` not covered
|
note: `Status` defined here
--> src/main.rs:2:6
|
2 | enum Status { Draft, Review, Published, Archived }
| ^^^^^^ -------- not covered
= note: the matched value is of type `&Status`
The error names the variant. Not "this match is incomplete" but &Status::Archived not covered, with the definition pointed at and the offending variant underlined.
Think about what this means for maintenance, because that is where the value is. Adding a variant to an enum is a change that should ripple — every place that decides behaviour per variant now has a decision to make. In a language with a switch statement and a default case, adding a variant silently routes it to the default, and you find out from a user. Here, adding Archived produces a compile error at every site that must be updated, and the compiler gives you the list.
That turns an enum into a refactoring tool. Want to know everywhere a document's status is consulted? Add a variant and build; the errors are the inventory. Teams working in languages without this reach for grep and hope.
The temptation to avoid is the wildcard arm. Writing _ => "unknown" makes the error go away and permanently disables the check for that match — so the next variant added will route silently to "unknown", which is exactly the behaviour you came to Rust to avoid. Wildcards are right when you genuinely mean "everything else, forever", as in matching a few specific numbers out of a range. They are wrong on an enum you control, and treating a compile error as noise to be silenced is how a codebase loses this guarantee one _ at a time.
The habit worth stealing from this is deliberately using the compiler as a worklist. When I need to extend a model — a new payment state, a new message kind — I add the variant first, with nothing else, and build. The resulting errors are a complete, machine-generated list of every decision point affected, in file order, and I work down it. It is the only refactoring workflow I know where the tool produces the checklist rather than me, and it only works if the codebase has stayed free of wildcard arms. That is the practical argument for being strict about _: not purity, but that every wildcard is a place the compiler will decline to help you later.
Patterns are a small language
match arms are not just variant names. Patterns are their own sublanguage, and the fluency is worth acquiring because it removes whole layers of conditional logic.
#[derive(Debug)]
enum Shipment {
Pending { items: u32 },
InTransit { items: u32, eta_days: u32 },
Delivered { items: u32, signed_by: Option<String> },
}
fn status(s: &Shipment) -> String {
match s {
// A guard: an extra condition on top of the pattern.
Shipment::Pending { items } if *items == 0 => "empty order".into(),
Shipment::Pending { items } => format!("{items} waiting"),
// Range patterns, and `@` to bind what matched.
Shipment::InTransit { eta_days: d @ 0..=1, .. } => format!("arriving ({d}d)"),
Shipment::InTransit { eta_days, items } => format!("{items} items, {eta_days}d away"),
// Nested pattern: match inside the Option, not on the Option.
Shipment::Delivered { signed_by: Some(name), .. } => format!("signed by {name}"),
Shipment::Delivered { items, signed_by: None } => format!("{items} left at door"),
}
}
fn main() {
let all = [
Shipment::Pending { items: 0 },
Shipment::Pending { items: 3 },
Shipment::InTransit { items: 2, eta_days: 1 },
Shipment::InTransit { items: 9, eta_days: 5 },
Shipment::Delivered { items: 1, signed_by: Some(String::from("ada")) },
Shipment::Delivered { items: 4, signed_by: None },
];
for s in &all { println!("{}", status(s)); }
}empty order
3 waiting
arriving (1d)
9 items, 5d away
signed by ada
4 left at door
Five distinct pattern features are doing work there. Guards add a condition with if, letting two arms share a shape and differ on a value. Range patterns like 0..=1 match a span of values. Binding with @ captures what matched while still constraining it, so d @ 0..=1 means "an eta of zero or one day, and call it d". .. ignores the remaining fields without naming them. And nested patterns reach inside — signed_by: Some(name) matches a delivered shipment that has a signature and binds the name in one step.
That last one is where the leverage is. Without nested patterns you would match Delivered, bind signed_by, then write a second match or if let inside the arm to see whether it is Some. The nested form flattens two levels of conditional into one arm, and the exhaustiveness check still covers every combination — which is why the two Delivered arms together are accepted as complete.
Arms are tried in order, so the guarded Pending arm must come before the general one. That ordering is the one place match behaves like a chain of ifs, and it is worth being deliberate about: a general arm placed too early makes later arms unreachable, and the compiler will warn you about that too.
The shorthands
Not every match needs all its arms spelled out, and Rust provides several abbreviations for the common shapes.
fn port_of(raw: &str) -> Option<u16> { raw.parse().ok() }
// `let ... else` handles the failure and must diverge — no nesting.
fn require_port(raw: &str) -> String {
let Some(p) = port_of(raw) else {
return String::from("no usable port");
};
format!("listening on {p}")
}
fn main() {
// if let: one pattern you care about, ignore the rest.
if let Some(p) = port_of("8080") { println!("if let {p}"); }
if let None = port_of("nope") { println!("if let nothing parsed"); }
println!("{}", require_port("443"));
println!("{}", require_port("http"));
// while let: keep going until the pattern stops matching.
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("while let popped {top}");
}
// matches! for a quick boolean test.
let v = port_of("22");
println!("matches! {}", matches!(v, Some(p) if p < 1024));
}if let 8080
if let nothing parsed
listening on 443
no usable port
while let popped 3
while let popped 2
while let popped 1
matches! true
if let is a match with one arm you care about and an implicit catch-all that does nothing. Reach for it when a single case is interesting and the rest genuinely need no handling.
let ... else is the one that most improves real code. It binds the pattern in the enclosing scope and requires the else branch to diverge — return, break, panic — so the happy path continues unindented afterwards. Compare it with the shape it replaces: an if let wrapping the entire rest of the function, with the error case at the bottom and the main logic drifting right. let ... else keeps the early-exit at the top and the real work at the margin, which is what makes functions with several fallible steps readable.
while let repeats until the pattern fails to match, which is exactly the shape of draining a collection. stack.pop() returns None when empty, and the loop ends — note the output comes out reversed, because popping takes from the end.
matches! is a macro producing a bool, for when you want a test rather than a branch, and it accepts guards too.
The design pressure these features create shows up most clearly in how Rust codebases model domain state. Teams coming from object-oriented languages often start with a struct carrying a status field and several optionals, then discover that every function begins with the same defensive checks and that nothing prevents an inconsistent combination. Rewriting the type as an enum typically deletes those checks entirely, because the invalid states stop existing. The reliable sign that this refactor is overdue is a struct where some fields are only meaningful when another field has a particular value — a comment saying "only set when status is Delivered" is an enum variant asking to be written.
Common Mistakes
Adding
_ =>to silence an exhaustiveness error. It permanently disables the check for that match, so the next variant added routes silently to the wildcard. Use it only when you genuinely mean "everything else, forever".Modelling states as a struct with a flag and optional fields. If some fields are meaningful only when another has a certain value, that is an enum. A comment describing which combinations are valid is the invariant asking to be moved into the type.
Reaching for
unwrap()by default. It is a panic waiting on a path you have not considered.unwrap_or,unwrap_or_else,mapandand_thenhandle most cases without ever extracting the value.Nesting
matchinside a match arm. Nested patterns usually flatten it:Delivered { signed_by: Some(name), .. }reaches two levels in one arm, and exhaustiveness still holds across the result.Putting a general arm before a specific one. Arms are tried in order, so a guarded case must precede the unguarded version of the same shape, or it can never match.
Using
if letwherelet ... elsebelongs. When failure means leaving the function,let ... elsekeeps the happy path unindented instead of wrapping the body in a conditional.Forgetting
Optionis a collection.flatten,iter,filter_mapand?all treat it as zero-or-one items, which usually reads better than explicit branching.Believing enums are expensive. An enum is its largest variant plus a discriminant, and the discriminant often disappears into a niche —
Option<Box<T>>is the size of the pointer.Matching on
&and then fighting the borrow. Matching a reference gives you references to the fields;*items == 0in a guard and binding by reference are normal, not a sign something is wrong.Treating
Noneas an error.Optionmeans absence, which is often legitimate. When the caller needs to know why something is missing, that isResult, and the next chapters cover the distinction.
Interview Corner
Work the TODOs in order, predicting each outcome first. TODO 1 is the exhaustiveness check used as a worklist — add the variant, build, and read the error as the instruction it is. TODO 2 is about nested patterns: the naive version needs a match inside a match arm, and the good version does it in one, so write both and keep the better one. TODO 3 has a deliberate catch — you will want one guarded arm covering three variants and the compiler will not let you bind the key uniformly across differently-shaped variants, so work out exactly why before reaching for a workaround. TODO 4 is the argument of the chapter in miniature: rewrite the match as if-let chains, confirm it still works, and then say precisely what the compiler stopped doing for you the moment you did.
#[derive(Debug)]
enum Command {
Set { key: String, value: String },
Get(String),
Delete(String),
List,
}
fn run(c: &Command) -> String {
match c {
Command::Set { key, value } => format!("SET {key}={value}"),
Command::Get(k) => format!("GET {k}"),
Command::Delete(k) => format!("DEL {k}"),
Command::List => String::from("LIST"),
}
}
fn main() {
let script = [
Command::Set { key: "mode".into(), value: "fast".into() },
Command::Get("mode".into()),
Command::Delete("mode".into()),
Command::List,
];
for c in &script { println!("A {}", run(c)); }
// TODO 1: add a `Rename { from: String, to: String }` variant. Do NOT
// touch `run`. Predict the error code, then compile.
// TODO 2: make Get carry an Option<String> default. Rewrite its arm to
// handle both cases in ONE arm using a nested pattern.
// TODO 3: add a guard so an empty key reports "invalid key" for Set,
// Get and Delete alike. Can you do it in one arm? Why not?
// TODO 4: replace the match in `run` with if-let chains. Compare them,
// and say what the compiler stops doing for you.
println!("B {}", run(&Command::List));
}A SET mode=fast
A GET mode
A DEL mode
A LIST
B LIST
Summary
- A Rust enum is a sum type: a value is exactly one variant, and each variant carries its own data in its own shape, which is not what
enummeans in C, Java or TypeScript. - This makes illegal states unrepresentable — a struct with a flag and fields that are only meaningful for certain values of it is an enum written the hard way.
- An enum occupies its largest variant plus a discriminant, and the discriminant frequently disappears into a niche, so the abstraction is close to free.
Option<T>is an ordinary library enum with no special compiler support, which is why Rust can have no null at all rather than a null that is merely discouraged.map,and_then,unwrap_orandunwrap_or_elsehandle most absence without unwrapping, andOptiondoubles as a zero-or-one collection forflattenandfilter_map.- Exhaustiveness is what makes enums binding: the compiler names the uncovered variant, which turns adding a variant into a machine-generated worklist of every site that must change.
- A wildcard arm permanently disables that check for that match, so it trades a guarantee for silencing one error, and belongs only where you mean "everything else, forever".
- Patterns are a sublanguage — guards, ranges,
@bindings,..and nesting — and nested patterns flatten what would otherwise be a match inside a match arm. - Arms are tried in order, so specific and guarded cases must precede general ones, and the compiler warns about arms made unreachable.
if let,let ... else,while letandmatches!cover the common shapes, andlet ... elsein particular keeps the happy path unindented when failure means leaving the function.
In the next chapter, Traits and What They Replace, we meet the only abstraction mechanism the language has — no classes, no inheritance, no interfaces in the Java sense — and see how one idea covers operator overloading, generic bounds, dynamic dispatch and the entire standard library's extensibility.
Milner's claim that well-typed programs cannot go wrong is easy to read as triumphalism, and it was never meant that way — he was defining "go wrong" quite narrowly, as a particular class of runtime type error, not promising that typed programs are correct. The distinction matters for this chapter. Exhaustive matching on a well-designed enum removes a real and large category of defect: the impossible combination, the unchecked flag, the field read in the wrong state. It removes nothing at all about whether your logic is right. You can handle every variant and handle them all wrongly, and the compiler will be perfectly satisfied. What the type system buys is that a certain kind of question stops needing to be asked, which frees attention for the questions that remain — and those are the ones that were always the hard part.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.