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, somatches!(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 means | Write |
|---|---|
| substitute a value | unwrap_or(default), unwrap_or_else(|| compute()), unwrap_or_default() |
| transform if present | map(|v| ...), and_then(|v| ...) for a function that also returns Option |
| becomes an error | ok_or(err), ok_or_else(|| err()) |
| narrows further | filter(|v| ...) |
| do something if present | if let Some(v) = opt |
| stop early | let Some(v) = opt else { return ... }; or ? in a function returning Option |
| cannot happen | expect("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?