All Places Patterns Can Be Used
A complete tour of every Rust construct that accepts a pattern, from match arms to function parameters, with examples and common mistakes.
Rust patterns are not limited to match. They appear in let bindings, function parameters, for loops, and several conditional constructs. Recognizing every place a pattern can go will let you write more direct code and understand why some compiles fail while others don't.
match Arms
match is the most visible pattern-heavy construct. Each arm is a pattern followed by => and an expression. The value being matched is checked against the patterns in order, and the first pattern that fits runs the corresponding expression.
match some_value {
Pattern1 => expression1,
Pattern2 => expression2,
// ...
}
Every match must be exhaustive: the set of patterns must cover every possible value of the matched expression. When the compiler cannot prove exhaustiveness, it refuses to compile the code.
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
The example above uses four simple identifier patterns that exactly match each variant. No catch‑all is needed because every Coin variant appears. A common way to guarantee exhaustiveness when you care only about some cases is the wildcard _ pattern, which discards the value without binding it.
Exhaustiveness Is Not Optional:
If a match does not cover all possible inputs, the compiler produces an error like non-exhaustive patterns. This is a compile‑time check, not a runtime fallback. You must either add missing arms or end with a catch‑all such as _ or a variable name.
The most frequent beginner mistake with match is assuming a wildcard arm will "just work" while forgetting that it still needs to produce a value of the correct type. Another common oversight is expecting match to compare against outer variables—patterns bind new names, they do not reference existing ones. We will revisit that shadowing behavior when discussing let and function parameters.
if let Expressions
if let is a concise way to match a single pattern while ignoring all other possibilities. It is equivalent to a match with exactly one meaningful arm and a wildcard arm that does nothing, but it reads more naturally when you only care about one case.
if let Some(inner) = optional_value {
println!("Got a value: {inner}");
} else {
println!("Nothing there");
}
if let can chain with else if and else if let, creating multi‑branch conditions that check different patterns against different expressions. Rust does not require those expressions to be related; each if let and else if stands on its own.
fn background_color(fav: Option<&str>, is_tuesday: bool, age_str: &str) -> String {
if let Some(color) = fav {
color.to_string()
} else if is_tuesday {
"green".to_string()
} else if let Ok(age) = age_str.parse::<u8>() {
if age > 30 {
"purple".to_string()
} else {
"orange".to_string()
}
} else {
"blue".to_string()
}
}
The if let Ok(age) = age_str.parse() line introduces a new age variable that shadows the outer age_str variable. This shadowing only lives inside the block immediately following the if let. You cannot combine a pattern match with an extra boolean condition on the newly bound variable in the same if let header; you must nest an if inside the body, as shown above.
No Exhaustiveness Check:
Unlike match, the compiler does not verify that an if let or else if let chain covers all possibilities. If none of the patterns match and there is no final else, the code silently does nothing. That can hide logic bugs when you forget to handle a case you expected to be caught.
A helpful mental model: if let is pattern matching for people who know exactly one shape they want to react to, without the ceremony of listing all the other shapes.
while let Loops
while let keeps running a loop body as long as a pattern continues to match. It is most often used with iterators or channels where a method returns Some or Ok for as long as data is available, then returns None or Err to signal the end.
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("Popped: {top}");
}
// prints: 3, 2, 1
Under the hood, while let desugars into a loop containing a match. The loop breaks when the pattern fails. This is why you cannot mix a while let condition with a guard that references the bound variable; the scope rules are the same as with if let.
The same pattern works with fallible operations that return Result.
use std::sync::mpsc;
use std::thread;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send(1).unwrap();
tx.send(2).unwrap();
tx.send(3).unwrap();
// tx goes out of scope here, closing the channel
});
while let Ok(value) = rx.recv() {
println!("Received: {value}");
}
recv() returns Ok(message) while the sender is still alive. Once the sender drops, the channel closes and recv() returns Err. The loop stops automatically without any explicit break.
Clean Resource Exhaustion:
while let is the idiomatic way to drain a data source until it signals completion. It replaces manual loop { match ... } boilerplate and keeps the termination logic obvious.
for Loops
The value directly after the for keyword is a pattern. In for x in iterator, the x is an irrefutable pattern that binds each successive element. The compiler requires this pattern to always match, because if a single element fails to match, the loop would have no defined continuation.
A for loop can destructure the elements produced by an iterator, which is especially useful when iterating over tuples.
let pairs = vec![(1, 'a'), (2, 'b'), (3, 'c')];
for (index, letter) in pairs {
println!("Index {index} has letter {letter}");
}
The enumerate() method on iterators pairs each item with its index, returning (index, value). Using a tuple pattern directly in the for loop avoids a separate destructuring step inside the body.
let colors = ["red", "green", "blue"];
for (i, color) in colors.iter().enumerate() {
println!("{i}: {color}");
}
Patterns Cannot Fail in a for Loop:
The pattern in the for loop must be irrefutable. If you attempt a refutable pattern like Some(x) in a for loop, the compiler will reject it. Use while let Some(x) = iter.next() instead for refutable per‑element matching.
let Statements
Every let statement is pattern matching. The syntax let PATTERN = EXPRESSION; evaluates the expression and attempts to match it against the pattern. When we write let x = 5;, the pattern is the identifier x, which is an irrefutable pattern—it matches any value and binds it to x.
Destructuring in let is a direct consequence of this design. The right‑hand side of let must be compatible with the pattern on the left.
let (x, y, z) = (1, 2, 3);
// x = 1, y = 2, z = 3
If the pattern cannot match the value, the program will not compile. The compiler checks at compile time that the pattern is irrefutable for the type of the expression. For a tuple literal like (1, 2, 3), the type is known, and let (x, y) would fail because the tuple has three elements but the pattern expects only two.
// This fails with "expected a tuple with 3 elements, found one with 2 elements"
let (x, y) = (1, 2, 3);
The requirement that let patterns be irrefutable extends to structs. You can destructure a struct only if the pattern matches the struct's shape.
struct Point {
x: i32,
y: i32,
}
let p = Point { x: 0, y: 7 };
let Point { x, y } = p;
// now x = 0, y = 7
let Patterns Must Be Infallible:
Writing let Some(value) = optional_value; will not compile unless optional_value is of a type that guarantees Some every time. If optional_value is Option<i32>, the compiler sees that None is possible and rejects the irrefutable pattern. Use if let or match when the match could fail.
A mental model for beginners: think of let as a single‑arm match that the compiler insists must always win. If the pattern can lose, the compiler asks you to be explicit about the losing case.
Function and Closure Parameters
Function parameters are patterns, identical in nature to let patterns. When you write fn foo(x: i32), the x is a pattern that binds the passed argument. This means function signatures can destructure arguments directly.
fn distance(&(x, y): &(i32, i32)) -> f64 {
((x.pow(2) + y.pow(2)) as f64).sqrt()
}
let point = (3, 4);
println!("{}", distance(&point)); // 5.0
Here the function takes a reference to a tuple and immediately destructures it into x and y. The & at the start of the pattern is a reference pattern that strips the outer reference, allowing x and y to be used as i32 values.
Struct destructuring in function parameters is equally direct and commonly used when a function only needs a few fields of a larger struct.
struct Rect {
width: u32,
height: u32,
label: String,
}
fn area(Rect { width, height, .. }: &Rect) -> u32 {
width * height
}
The .. ignores the label field. This pattern works because the parameter is irrefutable—every Rect has those fields.
Closures accept patterns in the same way. The iterator combinator map(|&x| x + 1) uses a reference pattern to dereference each borrowed element directly in the closure parameter list, saving a *x inside the body.
let nums = vec![1, 2, 3];
let incremented: Vec<i32> = nums.iter().map(|&x| x + 1).collect();
// incremented = [2, 3, 4]
Destructure at the Border:
Putting the pattern directly in the function or closure signature moves the destructuring to the earliest possible point. This makes the body cleaner and shows at a glance exactly which parts of the input are used.
Refutable Patterns Are Not Allowed:
Function and closure parameters, like let patterns, must be irrefutable. You cannot write fn first(Some(x): Option<i32>) because None would cause a runtime failure with no recovery mechanism. The compiler rejects such patterns at definition time.
Summary
Patterns are not a feature bolted onto match—they are a pervasive, uniform language mechanism that governs how values are taken apart and named throughout Rust. Every let, every function parameter, every for loop head is a pattern context. Some contexts require patterns that can never fail (irrefutable), while others accept patterns that may fail (refutable). Recognizing the difference and knowing which construct expects which category is the foundation for reading and writing Rust without fighting the compiler.