One Pattern, Three Structures: Structs, Tuples, Enums
A pattern can mirror the shape of the data it matches, down to arbitrary depth. Struct patterns name the fields they want and nothing else:
struct Point { x: i32, y: i32 }
let p = Point { x: 10, y: -4 };
match p {
Point { x: 0, y: 0 } => println!("origin"),
Point { x, y: 0 } => println!("on x-axis at {x}"),
Point { x, y } => println!("free point ({x}, {y})"),
}
Each arm's pattern restates the struct's fields with patterns in the value positions, so x: 0 tests for zero while bare x binds. Renaming happens with a colon — Point { x: left, y: top } binds the x field to a variable called left — and .. skips whatever you do not care about: Point { x, .. } matches every point and binds only x. Skipping fields is what keeps struct patterns practical; requiring all fields would make every refactor churn every match site.
Tuples follow the same rule with positional shapes: let (min, max) = bounds; destructures in a let, and match (a, b) { (0, _) => .., (x, y) if x == y => .., _ => .. } pairs values for comparison. Enums plug in by qualifying the variant, and because each pattern slot accepts a full pattern, structures nest freely:
enum Shape {
Circle(Point),
Rect { origin: Point, size: (u32, u32) },
}
match shape {
Shape::Circle(Point { x, y }) => println!("circle at ({x}, {y})"),
Shape::Rect { origin: Point { x: 0, y: 0 }, size: (w, h) } =>
println!("anchored {w}x{h}"),
Shape::Rect { size: (w, _), .. } => println!("width {w}, origin ignored"),
}
The middle arm is the whole idea in one line: an enum variant, a struct pattern inside it, and a tuple pattern inside that — one expression describes a precise nested shape and extracts four values from it. This is what interviewers mean by "patterns as data descriptions": the pattern is not filtering values so much as declaring the shape you care about and letting the compiler do the traversal. The same syntax works wherever a value can appear — let bindings, function parameters (fn draw(Point { x, y }: Point)), closures, and for loops — because destructuring is a property of patterns in general, not of match.
Tuple structs — the next topic's subject — take positional patterns, so destructuring them needs no field names at all:
struct Color(u8, u8, u8);
let red = Color(255, 0, 0);
let Color(r, g, b) = red; // irrefutable: all tuple-struct patterns are
println!("#{r:02x}{g:02x}{b:02x}");
And for loops destructure every item, which is why enumerate, tuples, and maps feel so natural:
for (index, user) in users.iter().enumerate() {
println!("{index}: {}", user.name);
}
for (key, (active, score)) in scoreboard {
println!("{key}: {} points ({active})", score);
}
When a pattern names a field that does not exist, the compiler is aggressive about helping — Point { x, z } produces "struct Point has no field z" plus a list of the fields it does have. That diagnostic is worth knowing about in a timed interview: read it, pick from the listed names, and move on rather than guessing at the syntax.
Ranges, Slices, `@` Bindings, and Guards
Patterns filter as well as destructure, and the filtering vocabulary is larger than most people expect.
Ranges test numeric and character spans, inclusive at both ends: 1..=9, 'a'..='f'. Slice patterns destructure lists, with .. standing for "any number of elements here":
match words.as_slice() {
[] => println!("empty"),
[single] => println!("one word: {single}"),
[first, .., last] => println!("'{first}' ... '{last}' ({} words)", words.len()),
}
The rest pattern .. can appear at most once per slice pattern, which is what stops "any number" from becoming ambiguous — the compiler knows exactly what the named parts pin down.
@ bindings capture a value while testing it, which is the trick for range-shaped questions with per-case consequences:
match age {
n @ 0..=12 => println!("child, ticket price for {n}"),
n @ 13..=17 => println!("teen, ticket price for {n}"),
n => println!("adult, ticket price for {n}"),
}
Without @ you would match the range, then look the value back up; with it, the binding and the test happen in one expression. @ also works with variants — n @ Packet::Size(w, h) is valid in newer editions, capturing the whole value and the extracted fields — though the common case remains ranges.
Guards add a boolean after the pattern: Point { x, y } if x == y => "diagonal". They refine a match without narrowing the pattern itself, which has the consequence you learned in match Expressions: guards never count toward exhaustiveness, so a guarded arm always needs an unguarded fallback beneath it.
One distinction keeps the whole system coherent: refutable vs irrefutable patterns. A pattern is refutable when some value fails it — Some(x), 0..=9, Point { x: 0, y }. It is irrefutable when nothing can fail — bare Point { x, y }, (a, b), ... match accepts refutable patterns because its job is to branch; let accepts only irrefutable ones, because there is no fallback arm. That is precisely why let Some(x) = opt; was an error before let ... else existed, and why while let is restricted to a single refutable pattern — the compiler enforces the same rule everywhere, and knowing the vocabulary predicts when it will complain.
Two final pieces of the vocabulary appear constantly in code review. Or-patterns with | let one arm cover several shapes, provided every alternative binds the same names with the same types:
match response {
Ok(0) | Ok(1) => println!("one packet at most"),
Ok(n) => println!("{n} bytes"),
Err(e) => eprintln!("{e}"),
}
Ok(0) | Ok(1) works because neither side binds anything; Some(n) | Ok(n) would be rejected unless the two ns had identical types. The rule exists so an arm's body can use its bindings without knowing which alternative matched.
And match ergonomics explain why you so rarely write ref in modern code. Matching a reference with a non-reference pattern automatically binds through the reference:
let p = Point { x: 1, y: 2 };
match &p {
Point { x: 0, .. } => .., // tests the point behind the reference
Point { x, y } => println!("({x}, {y})"), // x and y arrive as &i32
}
Before ergonomics, that required ref x and ref y at every binding; the keyword still exists for the rare case where you must choose borrow-versus-move explicitly inside a by-value pattern, but new code should reach for it almost never. If you inherit a codebase full of ref, that is a version marker, not a style you need to copy.
`if let`, `while let`, and Destructuring in `let`
match is the exhaustive tool; the single-pattern forms are for the cases where coverage would be noise.
if let runs one arm. The classic use is "handle the interesting shape, ignore the rest":
if let Shape::Rect { origin: Point { x: 0, y }, .. } = &shape {
println!("flush-left at y={y}");
}
Since Rust 1.65 it can carry an else, which must diverge — this is the same let-else idea applied to matching:
if let Ok(user) = session.current_user() {
greet(user);
} else {
return redirect_to_login();
}
while let is a loop that keeps running while the pattern matches — the idiomatic replacement for manual Iterator::next bookkeeping:
let mut stack = vec![3, 1, 2];
while let Some(top) = stack.pop() {
println!("{top}");
}
It works because pop() returns Option<T> and Some(..) is refutable: each iteration tests the pattern, and the first None ends the loop. The same shape drains channels (while let Ok(msg) = rx.recv()) and walks linked structures (while let Some(node) = cursor.next()). Note the explicit iter.next() form is rarely written — for loops call it for you — but while let earns its place when the source of the values is itself a Some-producing call rather than an iterator.
let ... else deserves a second look, because it inverted a long-standing rule: let patterns had to be irrefutable, so extracting a value from a Result forced nesting or unwrap. The else branch restores refutability by demanding an escape:
let (host, port) = parse_addr(addr) else {
return Err(ConfigError::BadAddress(addr.into()));
};
let Ok(rows) = db.query(sql).await else {
log::error!("query failed, serving stale cache");
return cache_snapshot();
};
Both branches of reasoning — patterns describe shape, and refutable patterns require a fallback — now cover match, if let, while let, and let-else with one model. When you reach for a control-flow form, ask two questions: does every case deserve a branch (use match), and is this a one-shot test or a repeated one (use if let or while let accordingly).
matches! sits on top of the same pattern language and is the quick shape-assertion tool in tests and conditions:
assert!(matches!(config.port, 1..=65535));
assert!(matches!(&shape, Shape::Rect { size: (0, _), .. } | Shape::Circle(_)));
assert!(matches!(parsed, Ok(n) if n > 0)); // guard inside matches!
Because matches! accepts any pattern — guards, or-patterns, nested destructuring — one line can express a condition that would otherwise take three. And for the newest addition to the family, Rust's "let chains" (stable in the 2024 edition) let multiple refutable tests share one condition:
if let Some(user) = session.current_user()
&& let Some(theme) = user.theme
&& theme.is_active()
{
apply(theme);
}
That replaces a pyramid of nested if lets with a single left-to-right condition, each clause scoping its bindings to the block. It is the same refutability model again — any clause failing skips the block — now composed across several patterns at once, which makes the four individual forms (match, if let, while let, let-else) feel like corners of one system rather than four separate features to memorize.
Quiz
1. In `match p { Point { x: 0, y: 0 } => .., Point { x, y } => .. }`, what does `x: 0` do versus bare `x`?
2. What does `n @ 13..=17` accomplish that `13..=17` alone does not?
3. Why must a guard like `Point { x, y } if x == y` be followed by an unguarded arm?
4. What do `if let`, `while let`, and `let ... else` have in common?
Flashcards
Question
How do you destructure a struct partially, and how do you rename bindings?
Click to reveal answer
Answer
`Point { x, .. }` binds only `x` and matches every point (`..` skips the rest); renaming uses `x: left` to bind the field to another name. Field slots hold full sub-patterns, so `Point { x: 0, y }` tests `x` for zero while binding `y`.
Question
What does the `@` operator do in a pattern, with an example?
Click to reveal answer
Answer
It binds the matched value while the rest of the pattern tests it: `n @ 13..=17 => ticket(n)` captures the number and restricts it to the teen range in one expression. It also works with variants — binding the whole value while extracting its fields.
Question
Refutable vs irrefutable patterns: define both and give an example of each.
Click to reveal answer
Answer
Refutable: some value fails it — `Some(x)`, `0..=9`, `Point { x: 0, y }`. Irrefutable: nothing fails — `Point { x, y }`, `(a, b)`, `..`. `match` takes refutable patterns (it has arms for the rest); plain `let` takes only irrefutable ones unless you add `else { diverge }`.
Question
When is `while let` the right tool, with a typical example?
Click to reveal answer
Answer
When values arrive as a `Some`-shaped result and you want to loop until the pattern fails: `while let Some(top) = stack.pop() { .. }`, `while let Ok(msg) = rx.recv() { .. }`. It replaces manual `match` + `break` around `next()`-style sources.
Question
What does `let ... else` require of its else branch, and why?
Click to reveal answer
Answer
The else branch must diverge — `return`, `break`, `continue`, or `panic!` — because the pattern is allowed to be refutable: if it doesn't match, the binding never happens, so the program must leave the block by some non-falling-through path.
Revision Notes
Key Takeaways
- 1. Patterns mirror data shape recursively — enum variant inside struct inside tuple — and extract every named part in one expression
- 2. `..` skips fields or slice elements; renaming uses `field: other`; literals in slots become tests rather than bindings
- 3. `@` captures a value while testing it, which is how range matches carry the value into the arm body
- 4. Guards refine without narrowing, so they never count toward exhaustiveness and always need an unguarded fallback
- 5. Refutability is the single concept behind `match` vs `let` vs `if let` vs `while let` vs `let-else` — one model, five forms
Interview Tips
- • "Destructure this nested enum-in-struct value" — write the full nested pattern and explain each slot is a sub-pattern; mention `..` to skip fields you don't need
- • "What does `x @ 1..=9` do?" — binds and tests simultaneously; follow up by showing the without-`@` alternative (match range, then re-read the value)
- • "Why doesn't a guarded arm satisfy exhaustiveness?" — guards are opaque booleans; coverage is computed from patterns alone, so an unguarded fallback is mandatory
- • "When do you use `while let` instead of `match` in a loop?" — when each iteration consumes a `Some`-shaped result; show `stack.pop()` or a channel `recv()` and note it replaces manual `match` + `break`
Cheat Sheet
Destructuring and Nested Patterns
Patterns compose: every slot in a pattern accepts another pattern.
match shape {
Shape::Rect { origin: Point { x: 0, y }, size: (w, _) } => ..,
Shape::Circle(Point { x, y }) => ..,
}
Toolkit:
| Construct | Meaning |
|---|---|
Point { x, .. } |
bind x, ignore other fields |
Point { x: left } |
rename while binding |
Point { x: 0, y } |
test x == 0, bind y |
[first, .., last] |
slice ends; .. once per pattern |
1..=9, 'a'..='f' |
inclusive ranges |
n @ 13..=17 |
test + capture in one pattern |
p if cond |
guard; does NOT count for exhaustiveness |
Refutability rule:
match — refutable OK (arms cover the rest)
let — irrefutable only, unless `let ... else { diverge }`
if let — one refutable pattern; else must diverge
while let — loop while the pattern keeps matching
Where patterns work: match, let, if let, while let, let-else, function params (fn draw(Point { x, y }: Point)), closures, for loops.