Pattern matching
match that checks every case, patterns that pull data apart while choosing, and the shorter if let and while let for when one case is all you care about.
match says every case out loud
match takes a value and a list of patterns. Each arm is a pattern, =>, and what to do. The compiler checks that the patterns cover everything the value could be — so a new enum option added later becomes a compile error at every match that has not considered it, rather than a bug at run time.
That exhaustiveness is the point. _ is the pattern for everything else, and it is fine for genuinely open values — but when the enum is yours, naming the cases beats hiding the rest behind an underscore.
Patterns pull the value apart
A pattern can do more than compare: it can bind the data inside. Shape::Circle(r) gives you the radius by name; Some(n) gives you the number inside the option; Point { x: 0, y } matches points on the axis and binds y. Ranges, alternatives with |, and guards (if n > 10) are all patterns too.
Because arms are expressions, a match is a value: let kind = match n { 0 => "zero", 1..=9 => "small", _ => "large" };. That is the shape you will write most often.
if let and while let
When only one case matters, if let Some(n) = maybe { … } says it without a full match and its _ arm. There is no "else if let" chain — two cases and a match is clearer — but one case is exactly what if let is for.
while let Some(top) = stack.pop() { … } loops for as long as the pattern matches, which is the natural way to drain a stack or a queue: the loop ends when pop returns None.
From full match to one case
fn describe(n: i32) -> &'static str {
match n {
0 => "zero",
1 | 2 => "one or two",
n if n < 0 => "negative",
3..=9 => "small",
_ => "large",
}
}
fn main() {
for n in [-4, 0, 2, 7, 40] {
println!("{}: {}", n, describe(n));
}
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("popped {}", top);
}
let maybe: Option<i32> = None;
if let Some(value) = maybe {
println!("value was {}", value);
} else {
println!("nothing to show — and the compiler still made us handle it");
}
}Read the cases out loud
Run it, then add a pattern for 100 and above that prints "a hundred or more", and add a grade: change the loop values to scores and print a letter for each with a match arm per band.
The _ arm is a promise to the compiler that you thought about the rest. If a new enum option appears after you wrote it, the code still builds — so use _ for values that are genuinely open-ended, and name your own cases.
Arm order is part of the meaning
Match arms are tried from the top, and the first pattern that fits wins. A broad arm placed above a narrow one makes the narrow one unreachable, and the compiler warns about it. Put the specific cases first, guards next, and the catch-all last — the list then reads in the same order as the decision.
Alternatives with | share one arm, but every alternative has to bind the same names if the arm uses them. 1 | 2 => ... is fine; Some(n) | None is not, because None has no n. The compiler rejects the second rather than inventing a value.
Guards and bindings
A guard is an if after the pattern: n if n < 0 => .... It is not part of the pattern language, so it cannot make a match exhaustive — a match with only guarded arms still needs a fallback. Guards are for conditions a pattern cannot express, like comparing a bound value with another value.
The @ symbol binds a value while also testing it: n @ 1..=9 gives you the whole number when it is in the range. Tuples and structs match the same way — (0, y), Point { x: 0, y } — and nested patterns pull a value apart in one step instead of three.
Matching through a reference
When the value you match on is borrowed, the patterns bind references. Matching &Some(3) against Some(n) gives n: &i32, because moving a value out of a borrow is not allowed. Match ergonomics hide most of this: the pattern looks the same whether the value is owned or borrowed, and the compiler adjusts the bindings to fit.
The rule to remember is that the arm receives a view, not a copy, so n cannot be changed unless the match was on a &mut. When the value itself is needed, *n copies it if the type is Copy, and clone() is the explicit choice when it is not. That is why a match over a slice of structs can read every field without copying any of them.
Three match mistakes. A _ arm added to silence the compiler also silences the next enum option you add, so use it only for genuinely open values. Matching a borrowed value and binding by value tries to move out of the borrow, which the compiler refuses; match through the reference and let the fields stay borrowed. And a guard does not count towards exhaustiveness, so guarded arms always need a fallback.
Try these next
- Write a
matchon a(bool, bool)pair — two switches — with an arm for each combination and no_. - Rewrite two
if letchecks as onematchon a tuple, and decide which version reads more clearly. - Add a guarded arm for negative scores to the grade function, and explain why the compiler still requires the final arm.
Points, quadrants and grade bands
Run it, then add a point at (3, 0) and one at (-2, -5). Then change the grade bands so that 90 and above is an A, and check that 100 still gets its own word.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
What does the compiler check about a match?
