Pattern Matching Deep Dive
Every tutorial in this series has leaned on match and if let without dwelling on them, matching Option, matching Result, destructuring an enum variant. The basic shape of a pattern is easy enough that it never needed its own explanation. What's worth a closer look is everything past that basic shape: guards, bindings, nested destructuring, and the control-flow forms that exist specifically to avoid deeply nested matches.
Match Guards: An if on Top of a Pattern
A guard adds a boolean condition to a match arm, checked after the pattern matches, but before that arm is chosen. It lets you express conditions a pattern alone can't, like comparing two bound variables.
fn classify(pair: (i32, i32)) -> &'static str {
match pair {
(x, y) if x == y => "equal",
(x, y) if x > y => "first is larger",
_ => "second is larger",
}
}
The exhaustiveness checker can't see into a guard's logic, if x == y is just an opaque expression to it, so a match with guards still needs a catch-all arm even if the guards look like they cover every case. The compiler has no way to verify that x == y, x > y, and "neither" together account for everything.
@ Bindings: Bind and Test at Once
A plain pattern either binds a variable (x) or tests a value (5, 1..=10), not both. @ lets you do both in one pattern: bind a name to a value while also requiring it to match a sub-pattern.
fn describe(age: u32) -> String {
match age {
n @ 0..=12 => format!("{n} is a child"),
n @ 13..=19 => format!("{n} is a teenager"),
n => format!("{n} is an adult"),
}
}
Without @, you'd need a guard instead (n if (0..=12).contains(&n)), which works but is more verbose for the common case of "bind it, and constrain it to a range."
Or-Patterns: One Arm, Several Shapes
| inside a single pattern matches any one of several alternatives, collapsing what would otherwise be multiple arms with identical bodies.
fn is_weekend(day: &str) -> bool {
matches!(day, "Saturday" | "Sunday")
}
enum HttpStatus {
Ok,
Created,
NotFound,
ServerError,
}
fn is_success(status: &HttpStatus) -> bool {
matches!(status, HttpStatus::Ok | HttpStatus::Created)
}
fn retry_delay_ms(status: &HttpStatus) -> Option<u64> {
match status {
HttpStatus::NotFound | HttpStatus::ServerError => Some(500),
_ => None,
}
}
Or-patterns work best when several variants genuinely share identical handling, like treating NotFound and ServerError the same for retry purposes above. Reach for a guard instead once you need to tell the alternatives apart inside the arm's body.
Nested Destructuring
A single pattern can reach through structs, enums, and tuples simultaneously, pulling out exactly the fields you need and ignoring the rest with ...
struct Address {
city: String,
zip: String,
}
struct User {
name: String,
address: Address,
}
let user = User {
name: "Ferris".to_string(),
address: Address { city: "Portland".to_string(), zip: "97201".to_string() },
};
let User { name, address: Address { city, .. } } = user;
println!("{name} lives in {city}");
This works identically inside match arms, and is especially useful for reaching into a specific enum variant's nested data without a chain of separate matches:
enum Event {
Click { x: i32, y: i32 },
KeyPress(char),
}
fn handle(event: &Event) {
match event {
Event::Click { x, y } if *x < 0 || *y < 0 => println!("click off-screen"),
Event::Click { x, y } => println!("click at ({x}, {y})"),
Event::KeyPress(c) => println!("key: {c}"),
}
}
Note there's no ref or manual dereferencing needed to get x and y as &i32 out of &Event::Click { .. }, match ergonomics (stable since Rust 2018) automatically inserts the borrows for you when matching on a reference.
if let and let else: Avoiding Match Pyramids
A match that only cares about one variant and ignores the rest is usually clearer as if let:
// verbose: only one arm actually does anything
match config.get("port") {
Some(port) => println!("port: {port}"),
None => {}
}
// clearer: same effect
if let Some(port) = config.get("port") {
println!("port: {port}");
}
The opposite need, bind a value or diverge (return, continue, break, or panic) if the pattern doesn't match, used to require an if let ... else with the happy path nested inside the if. let else (stable since Rust 1.65) flips that: the bound variable is available in the rest of the function, and only the failure path is nested.
fn parse_port(input: &str) -> u16 {
let Ok(port) = input.parse::<u16>() else {
return 8080; // must diverge: return, continue, break, or panic
};
port // available here, unnested
}
Compare that to the equivalent without let else, every line after the parse would have to live inside the if let block, nesting deeper with every subsequent fallible step. let else keeps the "main line" of the function flat and pushes only the error handling into a block.
matches!: A Boolean Without the Match
When all you need is "does this value match this pattern," writing out a full match that returns true/false is boilerplate. matches! is a macro that evaluates a pattern and returns a bool directly.
enum Status {
Active,
Suspended,
Banned,
}
fn can_log_in(status: &Status) -> bool {
matches!(status, Status::Active)
}
fn is_restricted(status: &Status) -> bool {
matches!(status, Status::Suspended | Status::Banned)
}
It supports guards too: matches!(value, Pattern if condition). Reach for it any time the only thing a match would produce is a boolean.
Key Takeaways
- Guards (
pattern if condition) add a check a pattern's shape alone can't express, but the compiler can't verify they're exhaustive, a catch-all arm is still required. @bindings let you bind a name while constraining it to a sub-pattern, most useful for "bind it, but only if it's in this range."- Or-patterns (
|) collapse multiple arms with identical bodies into one, but don't reach for them when you need to tell the alternatives apart afterward. - Nested destructuring reaches through structs, enums, and tuples in a single pattern; match ergonomics means you rarely need
ref/ref mutwhen matching on a reference. - Use
if letwhen amatchwould only handle one variant. Uselet elsewhen you need to bind a value or diverge, it keeps the success path flat instead of nesting it inside anif. matches!replaces amatchthat only ever returnstrue/falsewith a single expression.