rust · level 5

Enums & Pattern Matching

Tagged unions, exhaustive match, if-let / let-else, refutable patterns.

150 XP

Enums & Pattern Matching

If ownership is what makes Rust safe, exhaustive pattern matching is what makes it pleasant to refactor. Add a new variant to an enum and the compiler points at every place you forgot to update. No grep, no audit, no "I'll catch it in code review". The type system catches it.

Recap: enums are tagged unions

enum Message {
    Quit,                          // unit variant
    Move { x: i32, y: i32 },       // struct-like variant
    Write(String),                 // tuple variant (single field)
    ChangeColor(u8, u8, u8),       // tuple variant (multiple fields)
}

Each variant carries its own data. A Message is exactly one of these at any moment.

match — the exhaustive switch

fn handle(msg: Message) -> String {
    match msg {
        Message::Quit => String::from("bye"),
        Message::Move { x, y } => format!("move to ({}, {})", x, y),
        Message::Write(s) => s,
        Message::ChangeColor(r, g, b) => format!("rgb({},{},{})", r, g, b),
    }
}

If you delete one of those arms, the compiler emits:

error[E0004]: non-exhaustive patterns: `Quit` not covered

Add a variant to Message, and every match on Message in the codebase gets the same error. This is the killer feature — refactor with the compiler instead of without it.

Match arms bind data

The pattern on the left of => destructures the variant and binds names you can use on the right:

match shape {
    Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
    Shape::Rectangle(w, h) => w * h,
    Shape::Triangle { base, height } => 0.5 * base * height,
}

Field shorthand works in patterns too: Circle { radius } instead of Circle { radius: radius }.

Wildcards: _ and ..

_ matches anything but binds nothing:

match coin {
    Coin::Penny => 1,
    Coin::Nickel => 5,
    Coin::Dime => 10,
    Coin::Quarter(_) => 25,        // ignore the state inside Quarter
}

The bare _ arm at the bottom of a match catches the rest:

match key {
    "save" => save(),
    "quit" => quit(),
    _ => println!("unknown command"),
}

.. skips the rest of a struct or tuple:

match user {
    User { id, .. } => println!("id = {}", id),     // ignore name, email, etc.
}

Matching literals and ranges

match n {
    0 => "zero",
    1..=9 => "single digit",
    10..=99 => "two digits",
    _ => "many",
}

match c {
    'a'..='z' => "lowercase ascii letter",
    'A'..='Z' => "uppercase ascii letter",
    _ => "something else",
}

1..=9 is an inclusive range pattern — the only pattern form that uses ..=.

Or-patterns: |

match c {
    'a' | 'e' | 'i' | 'o' | 'u' => "vowel",
    _ => "not a vowel",
}

Multiple patterns on a single arm. Bindings must agree across alternatives:

match msg {
    Message::Move { x, .. } | Message::ChangeColor(x, _, _) => println!("{}", x),
    _ => {}
}

Match guards — if

A guard refines a pattern with a boolean:

match age {
    n if n < 0 => panic!("negative age"),
    0..=12 => "child",
    13..=19 => "teen",
    _ => "adult",
}

Important: guards don't count for exhaustiveness. match Some(x) { Some(x) if x > 0 => ..., None => ... } is not exhaustive — you still need a fall-through for Some(x) where the guard fails.

if let — match one variant

When you only care about one of several variants:

if let Some(x) = maybe_user {
    println!("user: {}", x);
}

// equivalent to:
match maybe_user {
    Some(x) => println!("user: {}", x),
    None => {}
}

if let ... else is also valid:

if let Some(cfg) = load_config() {
    run(cfg);
} else {
    eprintln!("missing config");
}

let else — Rust 1.65+

When a pattern should match but you want to early-exit if it doesn't, instead of nesting if let:

fn run(cfg: Option<Config>) {
    let Some(cfg) = cfg else {
        eprintln!("missing config");
        return;     // diverging — must return, break, continue, or panic
    };

    // cfg is bound in the surrounding scope from here onwards
    println!("running with port {}", cfg.port);
}

The else block must diverge — return, break, continue, or panic. The binding is scoped to the rest of the function. This eliminates the right-drift you'd otherwise get from chained if lets.

while let — loop until pattern fails

let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
    println!("{}", top);
}
// loop ends when pop() returns None

Refutable vs irrefutable patterns

A pattern is irrefutable if it always matches:

let (a, b) = (1, 2);            // tuple pattern matches any 2-tuple
let User { id, name } = u;      // struct pattern matches the struct

A pattern is refutable if it might not match:

let Some(x) = opt;              // ❌ refutable not allowed in plain `let`
                                // (opt could be None)

let binding requires irrefutable. if let, while let, let-else, and match arms accept refutable. The reason: a plain let has no fall-through path; a refutable pattern there would be a runtime crash waiting to happen.

@ bindings — match a pattern AND keep the value

match age {
    n @ 13..=19 => println!("teen of age {}", n),       // n binds here
    n @ 0..=12 => println!("child of age {}", n),
    _ => println!("other"),
}

name @ pattern lets you bind the matched value to a name and assert its shape.

Putting it together — a real example

enum Event {
    KeyPress { key: char, ctrl: bool },
    Click { x: i32, y: i32 },
    Resize(u32, u32),
}

fn dispatch(e: Event) {
    match e {
        Event::KeyPress { key: 'q', ctrl: true } => quit(),
        Event::KeyPress { key, ctrl: false } if key.is_ascii() => insert_char(key),
        Event::KeyPress { .. } => {}    // ignore other key combos
        Event::Click { x, y } => handle_click(x, y),
        Event::Resize(w, h) if w > 0 && h > 0 => layout(w, h),
        Event::Resize(_, _) => {}
    }
}

Five arms, mixed literal/struct/tuple/wildcard patterns, two guards. The compiler verifies coverage. Add Event::Scroll { dy: f32 } tomorrow and dispatch won't compile until you handle it.

When to reach for what

You want Use
Branch on every variant match
Branch on one variant, ignore the rest if let
Bind a value and bail if the pattern doesn't match let-else
Loop while the pattern matches while let
Check a refined condition on a matched value match guard if expr
Match value AND bind it name @ pattern
Catch the leftovers _ =>

Pattern matching is one of the highest-leverage features in Rust. It pairs with sum types to make illegal states unrepresentable — and the compiler keeps you honest as the system evolves.

Tools in the wild

4 tools
  • rustcfree tier

    Exhaustiveness errors are checked here — adding an enum variant lights up every incomplete match site.

    cli
  • clippyfree tier

    Lints `match_single_binding`, `redundant_pattern_matching`, `match_wildcard_for_single_variants`.

    cli
  • rust-analyzerfree tier

    Editor LSP — `Fill match arms` quick-action generates every missing variant scaffold.

    service
  • cargo-expandfree tier

    See how `if let`, `while let`, and `?` desugar to plain `match` expressions.

    cli