all_lessons/Rust/09 · Enums, Option and matchlesson 10 / 23

Enums, Option and match — absence and choice as types

Part II ended with a guarantee: a reference is never null and a moved-from name is dead, which leaves nowhere to put "nothing here" or "one of several shapes". This lesson turns both into types — an enum is exactly one of several variants, Option<T> is the enum for "maybe" — and makes match a proof, checked before the program exists, that every variant is handled. For a reference, absence turns out to cost nothing.

The thesis, here
A nullable pointer overpromises: it says "a Command" and sometimes delivers nothing. A sum type says exactly what it holds — "a Command, or nothing" — and match makes every reader handle every case the type admits: a finite proof, cheap, local and decidable.
Linear position
Forced by: References are always valid and never null, and a moved-from name is dead, so there is nowhere to put no value here, or one of several shapes. How do we represent absence and choice without inventing null?
New idea: absence and choice become types. An enum value is exactly one variant at a time, Option<T> is None or Some(T), and match is an exhaustiveness proof: the compiler shows that every value reaches an arm, or rejects the program (E0004). Patterns bind by moving, copying or borrowing, so the ownership law applies to them unchanged.
Forces next: Absence and choice are types now, and failure is just one more choice carrying a payload. Checking and forwarding it by hand at every call is noisy, and some failures are bugs rather than events to handle. How should a program report, propagate and stop on errors?
The plan
Seven moves. (1) Price the ways to say "nothing here". (2) Measure what an enum costs in memory. (3) Make match a proof and follow ownership through patterns. (4) Run the exhaustiveness algorithm yourself. (5) Tour the pattern language and refutability. (6) Read the Option toolkit as ordinary code. (7) Make illegal states unrepresentable.

1 · A function that may find nothing

A command interpreter keeps named macros ("greet" means say hello) and needs fn find(&self, name: &str) -> ???, returning a reference to the stored command (Lesson 05). When the name is missing it must return something, and Lesson 08 shut the obvious door: a reference is never null. The roads that remain, priced:

The null pointer is the C-era road, and this is the program the fourth road exists to exclude:

const Command* find(const Macros& m, const std::string& name);  // nullptr when absent

std::string text_of(const Macros& m) {
    const Command* c = find(m, "greet");
    return c->text;   // the day "greet" is missing, this reads through null: undefined
}

Nothing marks the line that forgot. The fourth road needs a type whose values are exactly one of several alternatives — a sum type, spelled enum. Each alternative is a variant, and each may carry its own payload:

enum Command {
    Quit,
    Move { dx: i32, dy: i32 },     // named fields
    Say(String),                   // one unnamed field
}

struct Macros {
    entries: Vec<(String, Command)>,
}

impl Macros {
    fn find(&self, name: &str) -> Option<&Command> {
        for entry in &self.entries {
            if entry.0 == name {
                return Some(&entry.1);     // present: carry the reference
            }
        }
        None                               // absent: a second shape, not a special address
    }
}

A value names its variant: Command::Quit, Command::Say(String::from("hi")). Option<T> is an ordinary library enum with variants None and Some(T) (the prelude lets you drop the Option:: prefix), so find returns maybe a reference, and a caller who treats it as a certain one is stopped:

enum Command { Quit, Move { dx: i32, dy: i32 }, Say(String) }
struct Macros { entries: Vec<(String, Command)> }

impl Macros {
    fn find(&self, name: &str) -> Option<&Command> { None }   // body as above
}

fn perform(cmd: &Command) { /* ... */ }

fn main() {
    let macros = Macros { entries: Vec::new() };
    perform(macros.find("greet"));     // as if "greet" were always there
}
error[E0308]: mismatched types
   |     ------- ^^^^^^^^^^^^^^^^^^^^ expected `&Command`, found `Option<&Command>`
help: consider using `Option::expect` to unwrap the `Option<&Command>` value, panicking if the value is an `Option::None`

The C++ bug cannot be written: absence must be decided before the reference exists. The suggested expect panics on None — a legitimate decision, but one the compiler proposes without knowing your program (§6).

A value can now be absent or one of several shapes. The budget allows no hidden price, so: what does one cost?

2 · What an enum is in memory

A Command must hold any one variant and say which. The direct layout is a tag — a small integer naming the live variant — beside room for the largest payload. Sizes are this compiler's choices (rustc 1.98.1, aarch64), so let it print them:

use std::mem::size_of;
use std::num::NonZeroU32;

enum Step { Quit, Move { dx: i32, dy: i32 } }                   // Command without Say
enum Command { Quit, Move { dx: i32, dy: i32 }, Say(String) }

fn main() {
    println!("Step {}  Command {}  String {}", size_of::<Step>(), size_of::<Command>(), size_of::<String>());
    println!("&u8 {}  Option<&u8> {}", size_of::<&u8>(), size_of::<Option<&u8>>());
    println!("Box<u8> {}  Option<Box<u8>> {}", size_of::<Box<u8>>(), size_of::<Option<Box<u8>>>());
    println!("u32 {}  Option<u32> {}", size_of::<u32>(), size_of::<Option<u32>>());
    println!("bool {}  Option<bool> {}", size_of::<bool>(), size_of::<Option<bool>>());
    println!("NonZeroU32 {}  Option<NonZeroU32> {}", size_of::<NonZeroU32>(), size_of::<Option<NonZeroU32>>());
    println!("Option<Option<bool>> {}", size_of::<Option<Option<bool>>>());
}
Step 12  Command 24  String 24
&u8 8  Option<&u8> 8
Box<u8> 8  Option<Box<u8>> 8
u32 4  Option<u32> 8
bool 1  Option<bool> 1
NonZeroU32 4  Option<NonZeroU32> 4
Option<Option<bool>> 1

Step is the textbook case: 8 bytes of payload and a tag, padded to i32's alignment — 12. Command is 24, the size of its String, with no room for a tag: some bit patterns are never a valid String (a null pointer is one; the Nomicon lists String among the types that hold a non-nullable pointer), and the compiler keeps the tag in them. Spare patterns like these are a niche, and they answer the budget question. Option<&u8> is as big as &u8 because None is the null pattern — guaranteed by the standard library for references, Box, function pointers and the NonZero integers (elsewhere it is this compiler's choice). Where every pattern is taken, the tag costs bytes: Option<u32> has 232 + 1 values to tell apart, so it needs another byte, padded to 8. NonZeroU32 gives up zero so that Option can use it; bool has spare patterns already; niches even nest. So "no null" costs nothing where C++ would have used null, and where it costs bytes, they are the flag you would have stored anyway. What remains is to make every reader of the tag handle every value.

3 · match: a proof over a place

match takes a scrutinee — the value examined, a place rather than a copy — and arms, each a pattern and an expression, and runs the first arm that fits. What is new: before the program runs, the compiler proves that every value of the scrutinee's type reaches an arm. Forget a variant and there is no program:

enum Command {
    Quit,
    Move { dx: i32, dy: i32 },
    Say(String),
}

fn describe(cmd: &Command) -> String {
    match cmd {
        Command::Move { dx, dy } => format!("move by ({dx}, {dy})"),
        Command::Say(text) => format!("say {text:?}"),
    }                                  // Quit: forgotten
}

Were it accepted, describe(&Command::Quit) would reach a match with no arm to run and no String to return — a state the language cannot describe. Exhaustiveness removes it at compile time, for free.

Reading the error
error[E0004]: non-exhaustive patterns: `&Command::Quit` not covered
  --> src/lib.rs:8:11
   |
 8 |     match cmd {
   |           ^^^ pattern `&Command::Quit` not covered
   |
note: `Command` defined here
...
 2 |     Quit,
   |     ---- not covered
   = note: the matched value is of type `&Command`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
It names a witness — a value no arm matches — and where its variant is declared; the & says we matched through a reference. What it cannot know is whether a Quit can really arrive here: its suggested arm, &Command::Quit => todo!(), compiles and panics when reached — a local edit, not a design. The fix is usually a real arm, sometimes a smaller type. And it does not say that its witness list is a sample: §4 shows which values it picks.
enum Command { Quit, Move { dx: i32, dy: i32 }, Say(String) }

fn describe(cmd: &Command) -> String {
    match cmd {
        Command::Quit => String::from("quit"),                  // the missing arm
        Command::Move { dx, dy } => format!("move by ({dx}, {dy})"),
        Command::Say(text) => format!("say {text:?}"),
    }
}

What a pattern does to ownership

A pattern also binds names to parts of the place, and each binding moves its part out, copies it (for Copy types) or borrows it — the modes of Lessons 04 and 05, without a & in sight:

fn main() {
    let nickname: Option<String> = Some(String::from("ada"));
    match nickname {
        Some(name) => println!("hello, {name}"),   // `name` takes the String out
        None => println!("hello, stranger"),
    }
    println!("{nickname:?}");                     // ...so the whole is gone
}
error[E0382]: borrow of partially moved value: `nickname`
4 |         Some(name) => println!("hello, {name}"),   // `name` takes the String out
  |              ---- value partially moved here
help: borrow this binding in the pattern to avoid moving the value

Some(name) moved the String out of nickname — a partial move (Lesson 04). Were the last line accepted, it would read a buffer that name dropped at the end of its arm: a use-after-free with no & anywhere. The fixes are the other two modes:

fn main() {
    let nickname: Option<String> = Some(String::from("ada"));
    match &nickname {                             // match on a shared borrow of the place
        Some(name) => {
            let _check: &String = name;           // so `name` is a reference, not the String
            println!("hello, {name}");
        }
        None => println!("hello, stranger"),
    }
    let count: Option<u32> = Some(3);
    match count { Some(n) => println!("{n}"), None => {} }   // u32 is Copy: n is a copy
    println!("{nickname:?} {count:?}");           // both still whole
}

A reference matched by a pattern with no & in it is dereferenced for you, and its bindings become references (the Reference's default binding modes); ref is the older explicit spelling. The law does not care whether a name came from let, from &, or from a pattern. And since the exhaustiveness proof decides whether your program exists, its algorithm is worth running by hand.

4 · How the compiler proves a match exhaustive

Write the arms as rows of a table, the pattern matrix, and ask the dual question: is there a value no row matches? The compiler answers one column at a time. It lists the column type's constructors — true and false; each variant; a tuple's single constructor; integer intervals cut at every boundary the arms mention. Constructors some row names are present; the rest are missing, and only rows with a wildcard in that column can match them. For each constructor it keeps the rows that can match, replaces the column by the constructor's fields, and recurses. A case that runs out of columns with no row left is a witness. An arm no case can reach — everything it matches is taken by earlier unguarded arms — is unreachable (a warning), and a guarded row covers nothing, because conditions are not evaluated.

One detail matters for reading errors: where a column has missing constructors, rustc reports only the witnesses under those and stays silent about gaps inside the present ones, so the E0004 list is true but incomplete. The widget shows both lists. Its engine, patterns.js, is differential-tested against rustc: tools/rust_verify/09_patterns.js compares the verdict, each unreachable arm and the E0004 line, character for character, on random matches; in ten runs of 800 (seeds 1 to 10) they agreed every time.

Is this match exhaustive?
One pattern per line, optionally with if … (a guard, never evaluated). The slider keeps the first k arms. Right: the checker's case split — green reached an arm, red is a witness, amber a witness rustc does not print. Light is enum Light { Off, Red, Amber, Green(bool) }.
exhaustive?
—
witnesses rustc prints
—
uncovered cases, all
—
unreachable arms
—
Show the core JS
// ── the core: every witness of matrix M, each tagged with whether rustc prints it ──
// baseCase(M, node), no columns left: the first unguarded row takes the case, and a guard never covers
function compute(M, st) {
  var node = record(M, st);
  if (M.cols.length === 0) return baseCase(M, node);
  var col = M.cols[0], ty = col.ty, out = [];
  var sp = splitCol(ty, M.rows.map(function (r) { return r.pats[0]; }));
  var individual = col.scrut || sp.present.length > 0;       // list the missing ones, or just say `_`
  describe(node, ty, sp);
  sp.present.concat(sp.missing.length ? [MISSING] : []).forEach(function (c) {
    // rustc prints what a present constructor finds only when nothing is missing in this column
    var S = specialize(M, c, c === MISSING || sp.missing.length === 0);
    if (c === MISSING) S.assign[col.pos] = { missing: missingLabel(ty, sp.missing, individual) };
    var w = compute(S, st);
    if (c === MISSING) {
      var heads = individual ? sp.missing.map(function (mc) { return wildFrom(ty, mc); }) : [WILD];
      heads.forEach(function (hd) { w.forEach(function (x) { out.push({ pats: [hd].concat(x.pats), rel: x.rel }); }); });
    } else {
      var n = arity(ty, c);
      w.forEach(function (x) { out.push({ pats: [wctor(ty, c, x.pats.slice(0, n))].concat(x.pats.slice(n)), rel: x.rel }); });
    }
    S.rows.forEach(function (cr) { if (cr.useful) M.rows[cr.parent].useful = true; });   // a row is useful if a child is
  });
  M.rows.forEach(function (r) { if (r.useful) r.pats[0].useful = true; });
  return out;
}

What to try. On Option<bool>, drag the slider to one arm. Some(true) alone is not exhaustive, and rustc prints only None, though the complete list also has Some(false) (amber in the tree): at the top, None is missing, so only what lies under it is reported. At two arms it names Some(false); at three the match is exhaustive; the fourth arm, Some(_), is unreachable. On (bool, bool) with one arm, rustc prints (false, _) and the complete list adds (true, false). On Light with four arms every variant is named, yet Light::Green(false) is uncovered — its only arm has a guard — which is why the fifth arm, _, is reachable. On u8 with four arms, 100_u8..=u8::MAX is uncovered for the same reason; at six, 42 is unreachable. Then type your own arms and predict before you look.

5 · Patterns, systematically

Every pattern is either a constructor the checker can split on — literals (0, true), ranges (1..=9, 10..100, 200..), destructured tuples, structs and variants — or a wildcard it cannot see into: _ binds nothing, a name binds everything. On top of those, .. skips the remaining fields, p | q matches either, d @ p names what p matched, and a guard, p if cond, adds a condition checked at run time:

enum Command { Quit, Move { dx: i32, dy: i32 }, Say(String) }

fn classify(cmd: &Command) -> String {
    match cmd {
        Command::Move { dx: 0, dy: 0 } => String::from("stay"),               // literals
        Command::Move { dx: d @ (1..=9 | -9..=-1), dy: 0 } => format!("step {d}"), // @, or, ranges
        Command::Move { dy: 0, .. } => String::from("slide"),                  // `..`: ignore the rest
        Command::Move { dx, dy } => format!("jump to ({dx}, {dy})"),           // bind both fields
        Command::Say(text) if text.is_empty() => String::from("silence"),      // a guard
        Command::Say(_) | Command::Quit => String::from("other"),              // `|`; `_` binds nothing
    }
}

fn main() {
    for c in [Command::Move { dx: -3, dy: 0 }, Command::Move { dx: 40, dy: 0 }, Command::Say(String::new()), Command::Quit] {
        println!("{}", classify(&c));
    }
}
step -3
slide
silence
other

The guard is the odd one out: it is ordinary code, and the checker does not run code, so no guard makes a match exhaustive, even one that is always true:

fn sign(n: i32) -> &'static str {
    match n {
        x if x < 0 => "negative",
        0 => "zero",
        x if x > 0 => "positive",      // true for every value left; the checker does not look
    }
}

fn main() { println!("{}", sign(3)); }
error[E0004]: non-exhaustive patterns: `i32::MIN..=-1_i32` and `1_i32..=i32::MAX` not covered
  = note: match arms with guards don't count towards exhaustivity

The repair is to say the last case with a pattern, which the checker can split. In the widget's u8 preset the guarded n if n >= 100 changes nothing at three arms (10_u8..=u8::MAX is still uncovered, as at two), and at five the pattern 100.. completes the proof.

A pattern that can fail (Some(name), 0..=9) is refutable; one that cannot (a name, (a, b) for a pair) is irrefutable. let takes only irrefutable patterns, for the reason behind Lesson 02's definite-assignment rule: after this line, what is name if nickname is None?

fn main() {
    let nickname: Option<&str> = None;
    let Some(name) = nickname;         // and if it is None, what is `name`?
    println!("{name}");
}
error[E0005]: refutable pattern in local binding
  = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
help: you might want to use `let...else` to handle the variant that isn't matched

Each refutable form answers that question: if let runs a block only on a match; let … else binds or runs an else that must leave, so afterwards the name surely exists; while let loops until the pattern fails; matches! turns a pattern into a bool.

fn greeting(nick: Option<&str>) -> String {
    let Some(name) = nick else {
        return String::from("hello, stranger");   // `else` must leave: return, break, panic
    };
    format!("hello, {name}")                      // from here on, `name` surely exists
}

fn main() {
    println!("{}", greeting(Some("ada")));
    println!("{}", greeting(None));

    let mut stack = vec!["b", "a"];
    if let Some(top) = stack.last() {             // one pattern, no else required
        println!("next up: {top}");
    }
    while let Some(item) = stack.pop() {          // loop until the pattern fails
        println!("popped {item}");
    }
    println!("empty: {}", matches!(stack.pop(), None));   // a pattern as a bool
}
hello, ada
hello, stranger
next up: a
popped a
popped b
empty: true

All of it is sugar over the same enums and the same proof, and so are the standard library's Option methods.

6 · The Option toolkit is ordinary code

The standard library wraps the small, common matches in methods (|t| t.len() is a closure, a function written in place; Lesson 14):

use std::mem;

fn main() {
    let mut title: Option<String> = Some(String::from("draft"));

    let len = title.as_ref().map(|t| t.len());            // Option<&String> → Option<usize>
    if let Some(t) = title.as_mut() {                      // Option<&mut String>: edit in place
        t.push_str(" two");
    }
    let space = title.as_ref().and_then(|t| t.find(' '));  // a step that may itself find nothing
    println!("{len:?} {space:?} {}", len.unwrap_or(0));

    let mut copy = title.clone();
    let old = title.take();                                // move out, leave None behind...
    let same = mem::replace(&mut copy, None);              // ...which is exactly this
    println!("{old:?} {title:?} | {same:?} {copy:?}");
    println!("{:?}", title.ok_or("untitled"));            // absence becomes an error value
}
Some(5) Some(5) 5
Some("draft two") None | Some("draft two") None
Err("untitled")

map transforms a present value and passes None through; and_then chains a step that may itself find nothing; unwrap_or supplies a default; ok_or turns absence into an error value (Result, Lesson 10). as_ref and as_mut are the ownership modes again: a borrow of an option becomes an option of a borrow, so the next step can look without taking. take is the move out of a &mut that Lesson 04 promised: it leaves None behind, exactly as mem::replace with None does. And unwrap asserts presence, panicking on None — a deliberate, well-defined crash, not undefined behavior:

fn main() {
    let port: Option<u16> = None;
    let p = port.unwrap();            // "I promise this is Some" — and it is not
    println!("{p}");
}

That is right where None would mean a bug in your own code, and wrong where absence is normal — Lesson 10's subject. An enum holds any choice we can name, and match checks every use — which pays most when it removes choices that should never exist.

7 · Make illegal states unrepresentable

Here is a connection written the C way — a flag and an optional socket — beside the same states as a sum type:

struct Socket { fd: i32 }

struct ConnFlags { connected: bool, socket: Option<Socket> }  // 2 × 2 = 4 states, 2 of them nonsense
enum Conn { Closed, Open(Socket) }                             // exactly the 2 legal states

fn main() {
    let odd = ConnFlags { connected: true, socket: None };     // compiles: "connected" to nothing
    let open = Conn::Open(Socket { fd: 3 });                   // the socket exists iff open
}

The flags admit two nonsense states, so every function must re-check an invariant the type does not hold; the enum cannot express them, because the socket is the payload of Open. The payoff comes when the design changes: add a state, and every match on Conn stops compiling until it says what to do.

struct Socket { fd: i32 }

enum Conn {
    Closed,
    Connecting { attempts: u8 },       // the state we just added
    Open(Socket),
}

fn status(c: &Conn) -> &'static str {
    match c {
        Conn::Closed => "closed",
        Conn::Open(_) => "open",
    }
}

fn close(c: Conn) -> Conn {
    match c {
        Conn::Open(sock) => { drop(sock); Conn::Closed }
        Conn::Closed => Conn::Closed,
    }
}
error[E0004]: non-exhaustive patterns: `&Conn::Connecting { .. }` not covered
  --> src/lib.rs:10:11
error[E0004]: non-exhaustive patterns: `Conn::Connecting { .. }` not covered
  --> src/lib.rs:17:11

The compiler has written the refactor's to-do list — every place the new state must be handled, and no other — unless a _ arm, a promise about variants that did not yet exist, swallows it:

struct Socket { fd: i32 }
enum Conn { Closed, Connecting { attempts: u8 }, Open(Socket) }

fn status(c: &Conn) -> &'static str {
    match c {
        Conn::Open(_) => "open",
        _ => "closed",                 // written when there were two variants...
    }
}

fn main() {
    println!("{}", status(&Conn::Connecting { attempts: 1 }));   // ...so connecting reports "closed"
}
closed
Road not taken · a zero value and a second result
Sum types are not new: the functional track builds Option and Either in Scala. Go chose differently: every type has a zero value (Go Lesson 02), pointers, maps and interfaces may be nil, and "maybe" is a second ok result (the comma-ok form, shown for type assertions in Go Lesson 06): a smaller language that leaves the check to the reader. Rust takes the functional road, at no more cost than the pointer C++ would use (§2).

One choice deserves attention of its own. A request that can fail for a reason is one more variant with a payload — enum Fetch { Done(String), TimedOut, Refused { code: u16 } } — so nothing new is needed to represent failure.

Common mistakes / failure modes

"Option is a nullable pointer"
It is an ordinary enum over any type: Option<u32> is 8 bytes with a real tag (§2). Only for pointer-like payloads does None sit in a niche and look like null, an optimization and not the definition.
"_ is harmless"
It turns the proof off for the future: add Conn::Connecting and _ => "closed" silently misreports it (§7). Name the variants where a match must be revisited when the enum grows.
"An always-true guard keeps the match exhaustive"
Guarded arms "don't count towards exhaustivity", says the compiler's note, so x if x > 0 leaves 1_i32..=i32::MAX uncovered (§5). Put the last case in a pattern.
"unwrap() is how you use an Option"
It is a panic you wrote on purpose (§6): right where None means a bug, wrong where absence is normal. Accepting the compiler's expect hint (§1) makes the same trade.

Checkpoint exercise

Try it
In the widget, choose Result<u8, bool> and replace the arms with Ok(0..=9) and Err(true). Predict what rustc prints; then delete Err(true) and predict again. Check in the widget. Answer: with both arms, both variants are present at the top, so both branches report: Ok(10_u8..=u8::MAX) and Err(false). With Ok(0..=9) alone, Err is missing at the top, so rustc prints only Err(_), though Ok(10_u8..=u8::MAX) is still uncovered, as the complete list shows. If you predicted Err(_) alone, you have rediscovered §4's sampling rule.

Where this points next

Absence and choice are types now, and match proves every case handled. Fetch showed that failure needs nothing new: it is one more variant, carrying the reason. A function that calls five fallible functions would need five matches, each handing the failure upward unchanged — correct, and noisy enough that people stop doing it. And not every failure deserves an arm: a None that can only mean a bug in our own code is a reason to stop, not an event for a caller. So: how should a program report a failure, pass it upward, and stop when it is a bug?

Takeaway
With no null and no moved-from state, absence and choice must be types: an enum holds exactly one variant at a time, and Option<T> is None or Some(T). The tag costs bytes only where the payload has no spare bit pattern, so Option<&T> and Option<Box<T>> are pointer-sized. match is a proof: the compiler splits the type into constructors and checks that every value reaches an arm (else E0004, naming a sample of witnesses), ignoring guards. Patterns bind by moving, copying or borrowing, the ownership law unchanged. let takes only irrefutable patterns; if let, let … else, while let and matches! take the rest. Make illegal states unwritable, and a new variant turns every affected match into a compile error.

Interview prompts

Companion reads: C++ · 03 Pointers and references, C++ · 15 Error handling (std::optional), Go · 02 The zero value, and FP · 11 Option, Try, Either.