The Book·Chapter 18·9 min

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 pattern x always matches.
  • if let Some(n) = opt — refutable, Some(n) might not match if opt is None.

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 binding

The 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 rest

Function 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.