Patterns and matching
Destructuring, refutability, match guards, bindings, and the let-else escape
Pattern matching is everywhere in Rust. match, if let, while let, let, function parameters, closure parameters. Once you internalize the syntax, large stretches of Rust read more like math notation than imperative code.
Pattern syntax basics
The atoms:
match value {
1 => println!("one"), // literal
2 | 3 => println!("two or three"), // alternation
4..=10 => println!("four to ten"), // inclusive range
n if n > 100 => println!("big: {n}"),// match guard with binding
n => println!("other: {n}"), // bind to a variable
_ => println!("anything else"), // wildcard, no binding
}The order matters. The first pattern that matches wins. The compiler warns on unreachable arms and on non-exhaustive matches.
Refutable vs irrefutable patterns
A pattern is irrefutable if it always matches; refutable if it might not.
let x = 5;— irrefutable, the patternxalways matches.if let Some(n) = opt— refutable,Some(n)might not match ifoptisNone.
let accepts only irrefutable patterns. if let, while let, match arms accept refutable ones. The compiler tells you when you have it wrong:
error[E0005]: refutable pattern in local bindingThe fix is usually to switch to if let or let ... else.
Destructuring structs
struct Point { x: i32, y: i32 }
let p = Point { x: 3, y: 7 };
let Point { x, y } = p; // shorthand: same names
let Point { x: a, y: b } = p; // rename while destructuring
let Point { x, .. } = p; // ignore the restFunction parameters can destructure too:
fn norm(Point { x, y }: &Point) -> f64 {
((x * x + y * y) as f64).sqrt()
}That signature reads "take a &Point, bind x and y to the fields." Cleaner than a body that starts with let Point { x, y } = p;.
Destructuring enums
enum Event {
Click { x: i32, y: i32 },
Scroll(i32),
Close,
}
match event {
Event::Click { x, y } => handle_click(x, y),
Event::Scroll(delta) => scroll(delta),
Event::Close => shutdown(),
}The exhaustiveness check is the whole point: add Resize { width, height } to Event and every match on Event fails to compile until you handle it. That is the compiler doing impact analysis for you.
Destructuring tuples and nested patterns
Patterns nest arbitrarily:
let pair = (3, (7, 9));
let (a, (b, c)) = pair;
match (status, value) {
(Status::Ok, Some(v)) => process(v),
(Status::Ok, None) => handle_missing(),
(Status::Err(code), _) => report(code),
}For long product/sum-type tuples, a nested match over both at once is often clearer than a chain of ifs.
Ignoring values
let (a, _, c) = triple; // ignore the middle
let Point { x, .. } = point; // ignore other fields
fn callback(_event: Event) { /* ... */ } // unused but named
fn handler((_, payload): (Header, Body)) { /* ... */ }_ is a wildcard that does not bind. .. is "everything else" in a struct or tuple. A name prefixed with _ binds but suppresses the unused-variable warning.
Match guards
match temperature {
n if n < 0 => "freezing",
n if n < 20 => "cool",
n if n < 30 => "warm",
_ => "hot",
}The if after the binding is the match guard. The compiler does not include guards in exhaustiveness checks, so you still need a catch-all arm.
@ bindings: capture and check at once
match age {
n @ 0..=12 => println!("child, {n} years"),
n @ 13..=19 => println!("teen, {n} years"),
n => println!("adult, {n} years"),
}The @ binds the matched value to a name while you also check a more complex pattern. Without it, you would have to recompute or write a guard.
if let, while let, let else
For the common "I only care about one variant" case, if let is concise:
if let Some(v) = lookup(key) {
process(v);
}
while let Some(job) = queue.pop() {
run(job);
}let ... else is the escape hatch for "extract or bail":
fn parse(input: &str) -> Result<Value, ParseError> {
let Some(rest) = input.strip_prefix("v1:") else {
return Err(ParseError::WrongVersion);
};
let Ok(payload) = serde_json::from_str(rest) else {
return Err(ParseError::BadJson);
};
Ok(payload)
}The else block must diverge: return, break, continue, panic!, or any expression of type !. After it, the bindings from the pattern are in scope for the rest of the function. This is a much cleaner shape than nested matches when you want early return.
When patterns get deep
A pattern that nests three or four levels usually means the data shape is fighting you:
match request {
Request::Search { query: Some(q), filters: Some(fs) }
if !q.is_empty() && !fs.is_empty() =>
{
run_search(q, fs)
}
_ => /* twelve more arms */
}When you see this, the fix is usually upstream: tighten the types so Options collapse, or split the request into clearer variants.
enum Request {
Search { query: NonEmptyString, filters: Vec<Filter> },
// other variants
}Now the function takes a Search, no Options, no nesting. The compiler stops reminding you to handle the empty case because the type system already did.