Patterns and Matching
Everywhere patterns work, refutable vs irrefutable, and the full destructuring grammar.
Part of Rust Interview Mastery · Based on The Rust Programming Language
Where Patterns Appear
List the places patterns can appear in Rust — most people only name match.
- match arms
- let bindings — let (x, y) = (1, 2) destructures
- if let / if let ... else (let-else)
- while let — loop while a pattern holds
- for loops — for (k, v) in map
- Function parameters — fn point(&(x, y): &(i32, i32))
- Closure parameters — same grammar
The insight: patterns are a general binding grammar, not a match-only feature — let is a pattern too, which is why let x = 5 works (x is the irrefutable bind-everything pattern).
Refutable vs irrefutable patterns — define them and explain why let requires one kind.
Irrefutable = always matches: x, (a, b), Point { x, y }. Refutable = might not: Some(v), Ok(r), 1..=10.
let requires irrefutable — let Some(x) = opt; can't compile: what would x mean when opt is None? The code must handle failure, so refutable patterns go in match/if let/let-else where a fallback exists.
The inverse corner: if let x = 5 — irrefutable in a place expecting refutable — compiles but warns "irrefutable if-let pattern" because the else is unreachable.
What is let-else solving that if let couldn't?
if let nests the happy path inside a block; let-else keeps the binding in the outer scope and pushes the failure path to bail:
let Ok(config) = load() else {
return Err("no config".into()); // must diverge
};
// config is bound HERE, same level as everything else
The else block must diverge (return, break, continue, panic!) — it can never fall through. It's the guard-clause pattern as syntax: flatten early-exits, keep the mainline un-indented.
The Pattern Grammar
Show off destructuring — structs, enums, tuples, nested.
let Point { x, y } = p; // fields by name
let Point { x: a, y: b } = p; // rename
let Point { x, .. } = p; // ignore rest
let ((feet, inches), Point { x, y }) = ((3, 10), p); // nested
match msg {
Message::Quit => ...,
Message::Move { x, y } => ..., // enum struct-variant
Message::Write(text) => ..., // tuple-variant payload
}
Destructuring works wherever patterns are allowed — function params, for, let. It's how Rust makes "take apart the data" read declaratively instead of field-by-field.
How do you ignore values in patterns — _, _x, .. differences?
let (x, _, z) = (1, 2, 3); // _: no binding at all
let _x = expensive(); // _x: binds but unused — silences warning, drops normally
let (a, .., z) = (1,2,3,4,5); // ..: ignore a range of items
match s { Ok(_) => "ok", Err(_) => "err" } // ignore payloads
_ vs _x matters in one case: _ doesn't take ownership at all (won't move/drop the value); _x binds (takes ownership, drops at scope end). For ..: skips contiguous items — struct { first, .., last }. Interview detail: _ as sole pattern can't be used later — _x could be (it's a real binding).
What are match guards and what's the shadowing trap inside them?
match num {
Some(n) if n < 5 => println!("small: {n}"),
Some(n) => println!("{n}"),
None => (),
}
if cond after the pattern adds a boolean check the pattern alone can't express (ranges don't cover comparisons on bound values).
The trap: a guard can use a variable shadowed by the pattern — Some(n) if n < y's n is the matched value, not an outer n. Guards run after the pattern binds, so they see the new bindings. Also: a guard failing doesn't mean the whole match fails — the arm is skipped and the next arm is tried.
What are @ bindings for — when do you need to bind and test a range at once?
match msg {
Message::Hello { id: id_var @ 3..=7 } => println!("in range: {id_var}"),
Message::Hello { id: 10..=12 } => println!("in another range"),
Message::Hello { id } => println!("{id}"),
}
name @ pattern does both: tests the value against the range AND binds it to id_var. Without @, you choose — bind (id) or test (3..=7), not both. It's the niche tool for "I need the value inside the arm AND the range check picked the arm."