The Rust Engineering Handbook / Chapter 14
Pattern Matching and Exhaustiveness
Use patterns to select legal cases while making moves, borrows, guards, and API evolution visible.
A legal state still needs a complete decision
Chapter 13 replaced a row of status flags with EntryState. That prevents a value from being pending and settled at once. It does not decide what the ledger should do with each legal state. That policy appears when the value is matched:
fn route(entry: &EntryState, limit: u64) -> Route {
match entry {
EntryState::Pending { amount, .. } if amount.cents() > limit => Route::Review,
EntryState::Pending { .. } => Route::Queue,
EntryState::Settled { .. } => Route::Archive,
EntryState::Rejected { .. } => Route::Repair,
}
}
The scrutinee is &EntryState, so this decision borrows the entry. The guarded arm refines Pending; the following unguarded arm remains necessary because the guard is an arbitrary boolean test, not structural coverage. If the enum later gains Reversed, this function stops compiling until someone chooses a route for it.
A pattern answers two questions at once: which values reach this arm, and what access the arm receives to their payloads. Exhaustiveness proves that the structural cases are covered. It cannot prove that Route::Repair is the right business decision, but it can ensure that the decision is visible.
Irrefutable patterns always fit
Function parameters, ordinary let, and for bindings require patterns that match every value of the input type. Tuple destructuring is irrefutable when every tuple has the same shape:
let (account, amount) = validated_pair;
An enum variant is refutable because the value might be another variant. Rust therefore rejects let EntryState::Pending { account, amount } = entry; unless failure has somewhere to go. Use match, if let, or let ... else when the shape can fail. The syntax records whether the surrounding control path accepts every value or only one case.
The scrutinee decides what an arm may take
The opening function classifies an entry and leaves it available to its caller. A transition often needs the opposite contract: consume the old state and carry its evidence into a new operation. Matching an owned value permits that transfer:
fn into_action(entry: EntryState) -> LedgerAction {
match entry {
EntryState::Pending { account, amount } => {
LedgerAction::Post { account, amount }
}
EntryState::Settled { receipt, .. } => LedgerAction::Archive(receipt),
EntryState::Rejected { reason, .. } => LedgerAction::Repair(reason),
}
}
Here account, receipt, and reason move when their types are not Copy; Amount moves too, although its small wrapper may implement Copy. After the match there is no entry to reuse. That is part of the function’s API, not incidental pattern punctuation.
If an arm needs only to inspect a field, match &entry. Match &mut entry when it must mutate through a unique borrow. Explicit ref and ref mut can borrow selected fields while matching an owned aggregate, but mixed move-and-borrow patterns deserve care: moving one non-Copy field can make the aggregate unusable as a whole even though an unmoved field remains accessible.
Match ergonomics reduce noise, not semantics
When a reference is matched with a non-reference pattern, default binding modes can turn the inner bindings into shared or mutable references. In route, amount is &Amount even though the pattern does not spell ref amount. This is match ergonomics: less notation around a borrow, not different ownership semantics.
For code review, annotate a confusing pattern with the inferred binding types or temporarily write explicit types in a helper. Avoid claiming that a match “copies the enum” unless the type is Copy and the expression actually does so.
Guards refine an arm after its pattern matches
A guard such as if amount.cents() > limit runs only after the structural pattern and its bindings succeed. Guards may read bindings and external state. They do not contribute to exhaustiveness because the compiler cannot generally prove what arbitrary boolean conditions cover.
match entry {
EntryState::Pending { amount, .. } if amount.cents() > limit => "review",
EntryState::Pending { .. } => "queue",
EntryState::Settled { .. } => "archive",
EntryState::Rejected { .. } => "repair",
}
Even complementary-looking guards such as if over_limit and if !over_limit do not satisfy the exhaustiveness checker; an unguarded Pending arm is still required. Keep expensive work and mutable external observations out of guards when they would make the apparent decision tree misleading. Compute such facts first and give them names.
Figure 14-1 turns the opening function into a recall map. Read the branch labels separately from the move/borrow legend: selecting a case and acquiring its payload are related decisions, not the same decision.
Let the number of real policies choose the construct
Use if let when one shape matters and all others genuinely share a path. A metrics hook may count rejections without pretending to decide the whole ledger policy:
if let EntryState::Rejected { reason, .. } = &entry {
rejected_entries.record(reason.code());
}
let ... else is stronger. It keeps successful bindings in the surrounding scope and requires the else branch to diverge with return, break, continue, or panic:
let EntryState::Pending { account, amount } = entry else {
return Err(TransitionError::NotPending);
};
post(account, amount)
Because entry is owned, the successful path moves its payload into account and amount. The rejected path returns before those bindings could be used. This is a good fit for a boundary whose remaining work has one required shape; it is a poor substitute for a match when settled and rejected entries demand different responses.
while let applies the same idea repeatedly. while let Some(entry) = pending.pop() both tests the Option returned by pop and moves the entry into the loop body. The call to pop makes progress visible. A loop that repeatedly matches an unchanged value can conceal nontermination behind compact syntax.
Use match when several cases carry distinct policy, when exhaustiveness should guard evolution, or when arms need different ownership. The shortest construct is useful only when it preserves the distinctions the system actually has.
Nested destructuring and bindings preserve context
Patterns can destructure nested tuples, structs, enums, slices, and references. They are valuable when the nesting itself is the fact under review. Suppose a retry queue carries both an entry and its next attempt number:
match work {
Work::Route {
entry: EntryState::Pending { amount, .. },
attempt: retry @ 1..=3,
} if amount.cents() > limit => schedule_review(retry, amount),
other => handle_normally(other),
}
The nested pattern reaches the pending amount. retry @ 1..=3 both restricts the accepted range and retains the matched attempt number. .. ignores fields this decision does not need.
The catch-all is credible here only if handle_normally truly owns every other case. If normal handling differs by state, the compact arm has erased policy. Dense patterns have the same failure mode: once a reviewer must simulate several nesting levels, alternations, and guards, extract a smaller fact or helper. Keep exhaustive handling at the domain-state boundary and pass the helper only what it needs.
Catch-all arms trade evolution alarms for tolerance
_ or other covers everything not matched earlier. This is correct for open input spaces such as unknown numeric codes that must be logged and rejected. It is risky for a closed internal enum: adding a variant will not force the match to be reconsidered.
For public #[non_exhaustive] enums, downstream code must have a fallback because another crate cannot assume it knows every future variant. Give that fallback an explicit policy: return an unsupported error, preserve the raw value, or choose documented conservative behavior. Inside the defining crate, enumerate known variants when a new case should trigger a compiler-driven audit.
The compile-fail fixture in domain-model-lab/ui/non_exhaustive.rs omits Rejected. Its expected E0004 failure is evidence that the decision tree is incomplete, not an error to silence with _ automatically.
These are two different evolution contracts. A closed internal enum can make every added variant interrupt compilation at policy sites. An open public enum gives its defining library room to grow, but downstream callers must already know what they will do with cases they cannot name. Neither contract is universally safer. Choose who must absorb the next variant and when.
Failure modes
- Matching an owned enum and accidentally moving a payload needed later.
- Adding
cloneinstead of matching a reference when only inspection is needed. - Assuming a guard participates in exhaustiveness.
- Using
_on an internal enum and missing a new state transition. - Flattening distinct states into the ignored path of
if let. - Writing a nested pattern that is denser than the decision it represents.
- Using
while letwithout a visible state change.
Senior review checklist
- Is the scrutinee owned, shared-borrowed, or mutably borrowed?
- What is the inferred type of every important binding?
- Are all domain cases explicit where evolution should trigger review?
- Does a guard refine a structurally covered case rather than pretend to cover it?
- Does a catch-all have an intentional forward-compatibility policy?
- Would
let ... elsemake a boundary failure flatter and clearer? - Are nested patterns clarifying structure or hiding policy?
- Does every loop pattern make progress and preserve ownership intent?
Refactoring exercise: make a new state interrupt the right code
Begin with a flag-driven ledger function that routes, records metrics, and consumes receipts or rejection reasons in one body. Replace its input with EntryState, then separate a borrowing classification from a consuming action. For each important binding, write down its inferred type and whether the caller can use the entry afterward.
Now add Reversed { account, amount, original_receipt }. Before repairing the compiler errors, list the sites that should be forced to choose policy and the sites that should tolerate an unknown case. Use exhaustive matches at the former. Use if let or a documented fallback at the latter only when the ignored cases truly share behavior. Finally drain several owned entries from a Vec with while let, and explain what operation guarantees progress. The result should make the new state’s policy work visible without turning every incidental observation into an exhaustive decision.
Durable takeaways
- Patterns select structure and bindings determine access or ownership.
- Irrefutable contexts cannot reject a shape; refutable constructs make failure visible.
- Guards run after pattern matching and do not prove exhaustiveness.
- Catch-all arms are a compatibility policy, not harmless convenience.
- Exhaustive internal matches turn domain evolution into a compiler-assisted audit.
An exhaustive match makes one structural decision complete and a consuming pattern makes ownership transfer explicit. Real programs must then carry those decisions across calls, iterator steps, and early exits without flattening absence, failure, or a deliberate stop into the same branch. Those outcomes need distinct control types.
Sources and version notes
- Rust Reference: patterns and match expressions
- Rust Reference:
if letandletstatements - Compiler behavior and E0004 fixture verified with Rust 1.97.0 and Rust 1.85.0.
Continue reading
Full table of contents