Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Enums, Matching, and the Absence of Null

Tony Hoare introduced the null reference to ALGOL W in 1965 and called it his billion-dollar mistake in a talk forty-four years later, on the grounds that it had caused “innumerable errors, vulnerabilities, and system crashes”. The interesting part of his account is why he did it: it was easy to implement, and the type checker could not object.

That last clause is where the trouble starts. Null is a value that belongs to every reference type while satisfying none of their contracts, so the type system cannot distinguish a String from a maybe-String, and every piece of code that handles one must be written as though it might be handling the other. The discipline required is uniform vigilance, and uniform vigilance is not something humans supply.

Rust has no null. What it has instead is an enum, and this chapter is about the enum and the pattern matching that makes it work — the mechanism that most distinguishes reading Rust from reading the languages you already know.

Match is the primary control structure

match takes a value and a set of patterns, finds the first pattern that fits, binds any names in it, and evaluates the corresponding arm:

enum Command {
    Quit,
    Move { x: i32, y: i32 },
    Say(String),
    Repeat(Box<Command>, u32),
}

fn describe(command: &Command) -> String {
    match command {
        Command::Quit => String::from("quit"),
        Command::Move { x, y } => format!("move to {x},{y}"),
        Command::Say(message) => format!("say {message:?}"),
        Command::Repeat(inner, times) => {
            format!("{} times: {}", times, describe(inner))
        }
    }
}

fn main() {
    let script = Command::Repeat(Box::new(Command::Say(String::from("hello"))), 3);
    println!("{}", describe(&script));
}

Three things in that example are worth separating out.

A pattern destructures as it matches. Command::Move { x, y } both tests which variant is present and pulls the fields out as local bindings, so there is no separate cast and no accessor call. In a language with a sealed hierarchy you would test the type, cast, and then read the fields; the pattern does all three and cannot get them out of step.

A match is an expression. Every arm produces a value, the arms must agree on a type, and the result is the value of the construct. That is why the function has no return and no mutable accumulator.

And Command::Repeat holds a Box<Command>, which is how a recursive type is written. A Command containing a Command directly would have no finite size, so the recursion goes through a box, one pointer wide regardless of what it points at. Every tree-shaped type in Rust — expression trees, JSON documents, directory hierarchies — is built this way.

Exhaustiveness is a refactoring tool

The compiler requires that a match cover every possible value, and rejects one that does not. On its own that prevents a forgotten case. What makes it valuable, though, is what happens to a program when a variant is added.

Suppose Command gains a Wait(u32) variant. The describe function above no longer compiles, and the error names the file, the line, and the variant that is not handled. If the program has forty matches on Command, you get forty errors, which is a complete work list, generated by the compiler, of every place in the codebase that has to think about waiting.

Compare the same change in a language that switches on a type tag or a string. It compiles. The tests that cover the new case fail, the ones that do not, pass, and the missing branch surfaces in production some weeks later in a module nobody associated with the change. The difference is not that Rust programmers are more careful. It is that adding a variant is a mechanical, compiler-verified refactor in one language and an archaeology exercise in the other.

This leads directly to a rule about the wildcard pattern. _ matches anything, which you want sometimes and which also switches exhaustiveness off:

#![allow(unused)]
fn main() {
enum Command { Quit, Move { x: i32, y: i32 }, Say(String) }
fn is_quit(command: &Command) -> bool {
    match command {
        Command::Quit => true,
        _ => false,
    }
}
}

That is fine, because the question really is “quit or not” and a new variant should indeed answer false. But a _ arm in a function that genuinely needs to handle each case is a silent opt-out from the work list, and it is how a carefully designed enum stops paying for itself. The habit worth building is to ask, each time you write _, whether a future variant should fall into it. When the answer is no, list the variants explicitly and let the compiler find you later.

Note

For the “is it this one” question specifically, matches! is shorter and makes the intent clearer than a two-armed match: matches!(command, Command::Quit). It takes patterns, including guards, so matches!(n, 1..=9) works too.

Patterns are a small language

Patterns appear in match, in if let, in let, and in function parameters, and they are more capable than the examples so far suggest.

#[derive(Debug)]
enum Event {
    Key { code: u8, shift: bool },
    Click { x: i32, y: i32, button: u8 },
    Scroll(i32),
}

fn interpret(event: &Event) -> String {
    match event {
        Event::Key { code: 27, .. } => String::from("escape"),
        Event::Key { code, shift: true } if *code >= b'a' && *code <= b'z' => {
            format!("shifted {}", (*code as char).to_ascii_uppercase())
        }
        Event::Key { code, .. } => format!("key {code}"),
        Event::Click { button: 1 | 2, x, y } => format!("click at {x},{y}"),
        Event::Click { button, .. } => format!("unknown button {button}"),
        Event::Scroll(amount @ i32::MIN..=-1) => format!("scroll up {}", -amount),
        Event::Scroll(0) => String::from("no scroll"),
        Event::Scroll(amount) => format!("scroll down {amount}"),
    }
}

fn main() {
    println!("{}", interpret(&Event::Key { code: 27, shift: false }));
    println!("{}", interpret(&Event::Click { x: 10, y: 20, button: 1 }));
    println!("{}", interpret(&Event::Scroll(-3)));
}

A literal in a field position matches only that value. .. ignores the rest of the fields. A guard after if adds a condition the pattern itself cannot express. | matches alternatives. A range pattern matches a span of values. And name @ pattern binds the matched value to a name while still constraining it, so amount is available in an arm that also restricts its range.

Patterns nest, so a Some(Event::Click { button: 1, .. }) is a single pattern, and reaching three or four levels into a structure in one line is ordinary Rust. This is the feature that most changes how code looks. Logic that would be a staircase of type tests and null checks in another language flattens into a table of shapes and their responses.

Option, and deciding at the point of use

Option<T> is an ordinary enum — Some(T) or None — with no special privileges. It replaces null by being a different type from T, so the compiler knows which one you are holding and will not let you use a maybe-value as a value.

The consequence is that absence has to be handled where it is introduced rather than surfacing later as a crash. What you write at that point depends on what absence means there, and the standard library provides a vocabulary so that the answer is usually one short line:

When absence meansWrite
substitute a valueunwrap_or(default), unwrap_or_else(|| compute()), unwrap_or_default()
transform if presentmap(|v| ...), and_then(|v| ...) for a function that also returns Option
becomes an errorok_or(err), ok_or_else(|| err())
narrows furtherfilter(|v| ...)
do something if presentif let Some(v) = opt
stop earlylet Some(v) = opt else { return ... }; or ? in a function returning Option
cannot happenexpect("why it cannot happen")

Put together, these let a chain of fallible steps read as one expression:

use std::collections::HashMap;

fn display_name(directory: &HashMap<u32, String>, id: u32) -> String {
    directory
        .get(&id)
        .map(|name| name.trim())
        .filter(|name| !name.is_empty())
        .map(|name| name.to_uppercase())
        .unwrap_or_else(|| format!("user {id}"))
}

fn main() {
    let mut directory = HashMap::new();
    directory.insert(1, String::from("  ada  "));
    directory.insert(2, String::from("   "));
    println!("{}", display_name(&directory, 1));
    println!("{}", display_name(&directory, 2));
    println!("{}", display_name(&directory, 3));
}

Three different ways of having no name — missing key, blank string, unknown id — collapse into one path, and none of them can produce a crash further down. The equivalent written with null checks is a dozen lines and has a hole in it.

let else deserves a separate mention because it is the shape that most improves ordinary imperative code:

fn parse_port(raw: &str) -> Option<u16> {
    let Ok(port) = raw.parse::<u16>() else {
        return None;
    };
    if port < 1024 { None } else { Some(port) }
}

fn main() {
    println!("{:?} {:?}", parse_port("8080"), parse_port("80"));
}

The binding stays in scope for the rest of the function rather than being trapped inside an if let block, so guard clauses stay flat instead of marching to the right.

The unwrap question

unwrap takes the value out of a Some and panics on None. expect does the same with a message. Both are the escape hatch, and how you feel about them is a reasonable proxy for how much Rust you have written.

Newcomers use them everywhere, because they make the compiler stop asking questions. That produces a program that crashes exactly where a null-dereferencing program would, having spent the ceremony and collected none of the benefit.

The overcorrection is to treat them as forbidden, which produces elaborate handling for cases that cannot occur and error types with variants that are never constructed.

The line is whether you can state, in one sentence, why None is impossible here — and whether that sentence is a fact about the code rather than a hope. A regular expression compiled from a literal that you wrote and that the tests exercise will parse; expect("literal pattern is valid") is a statement of that. An element you inserted two lines ago is present. In tests, a failed unwrap is a failed test, and that is the correct outcome.

What fails the line is anything involving input, configuration, the network, the filesystem or another process. Those are the cases the type was drawing your attention to, and unwrap there is a decision to crash rather than an argument that crashing is impossible.

Prefer expect to unwrap whenever you do reach for one. The message costs nothing and turns a panic that says called Option::unwrap() on a None value at some line number into one that says why the author thought it could not happen — which is precisely the information the person debugging it needs.

Enums carry behavior too

An enum is a type, so it has impl blocks like any other, and putting behavior on the enum rather than in a free function keeps the match next to the thing it matches on:

enum Temperature {
    Celsius(f64),
    Fahrenheit(f64),
    Kelvin(f64),
}

impl Temperature {
    fn as_celsius(&self) -> f64 {
        match self {
            Temperature::Celsius(degrees) => *degrees,
            Temperature::Fahrenheit(degrees) => (degrees - 32.0) * 5.0 / 9.0,
            Temperature::Kelvin(degrees) => degrees - 273.15,
        }
    }

    fn is_freezing(&self) -> bool {
        self.as_celsius() <= 0.0
    }
}

fn main() {
    let readings = [
        Temperature::Fahrenheit(28.0),
        Temperature::Kelvin(300.0),
        Temperature::Celsius(12.5),
    ];
    for reading in &readings {
        println!("{:.1}C freezing={}", reading.as_celsius(), reading.is_freezing());
    }
}

There is a design tension here worth naming, because it is the one place where enums and traits genuinely compete. Adding a variant to an enum means touching every match; adding an operation means one new method in one place. With a trait and a set of implementing types it is reversed: a new type is one new impl and touches nothing, while a new operation means editing every implementation.

So the choice follows the axis you expect to grow. A closed set of cases with operations that keep accumulating — a syntax tree, a protocol message, a set of states — wants an enum. An open set of implementations sharing a stable set of operations — a plugin, a storage backend, a renderer — wants a trait. Chapter 9 takes up the other half.

Conclusion

Null exists because a type system was allowed to look away. Rust’s replacement is an ordinary enum, which the type system cannot look away from, and the machinery that makes ordinary enums pleasant to use — destructuring patterns, guards, ranges, exhaustiveness checking — is the same machinery that makes Option and Result practical rather than tedious.

Exhaustiveness is the feature with the longest reach. It turns adding a case from an exercise in remembering into a compiler-generated work list, and the price of keeping that property is being deliberate about the wildcard pattern.

For absence specifically, the thought to build is that None is a case to be answered at the point where it appears, and that the standard library has a short way to write nearly every answer. unwrap is one of those answers, appropriate when you can say why the absent case cannot occur, and a decision to crash when you cannot.

The next chapter takes the same apparatus and applies it to failure, the other thing every program has to represent and most languages represent badly.

Questions to think through

Pick a switch statement in code you know that dispatches on a type tag or a string constant. What happens today when someone adds a new case and misses that switch? What would have to be true for the compiler to find it?

The chapter claims _ silently opts out of exhaustiveness checking. Find a default: branch in a language you use and decide whether a newly added case falling into it would be correct or a bug.

Take three unwrap-shaped operations from your own code — a cast, a lookup, a parse. For each, write the sentence explaining why failure is impossible. Which sentences are about the code, and which are about what you expect the world to do?