When one variant is all you need
You're building a command-line tool. The configuration enum has twelve variants: Debug, LogLevel, Port, Host, Timeout, and so on. Your current function only needs to handle Debug. You write a match statement. Eleven arms end with _ => {}. The compiler complains about unused variables in the other arms. You add underscores everywhere. The code looks like a patchwork of noise.
if let cuts through that clutter. It lets you grab exactly the variant you want and ignore the rest without ceremony. You describe the shape you're looking for. If the value fits, you get the pieces. If it doesn't, the code moves on. No wildcard arms. No dummy bindings. Just the logic that matters.
Stop writing _ => {} arms. They're noise.
The shape check
Think of match as a traffic cop directing every car to a specific lane. Every car must go somewhere. The compiler enforces this. If a new car type appears, the cop needs a plan for it.
if let is a toll booth that only cares about trucks. Cars pass by unnoticed. Trucks stop, pay, and get processed. If a car shows up, the toll booth does nothing. It's a conditional pattern match. You define a pattern. Rust checks if the value matches that pattern. If yes, the block runs with the extracted data. If no, the block is skipped entirely.
Pattern matching is about shape, not just value.
Minimal example
Here's the basic syntax. You write if let followed by a pattern, an equals sign, and the value to check.
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
fn main() {
let shape = Shape::Circle { radius: 5.0 };
// Only care about circles. Ignore rectangles and triangles.
// Bind `radius` if the variant matches.
if let Shape::Circle { radius } = shape {
println!("Circle area: {}", 3.14 * radius * radius);
}
// This runs regardless of the shape.
println!("Done checking.");
}
The pattern Shape::Circle { radius } specifies the variant and extracts the radius field. If shape is a Rectangle, the condition is false. The block is skipped. The program continues to println!("Done checking.");.
No panic. No default case. Just a check and a move.
What happens under the hood
At compile time, Rust verifies that the pattern can match the type of the value. If you try if let Shape::Square { side } = shape and Square doesn't exist, the compiler rejects it immediately. This prevents typos from silently failing.
At runtime, Rust checks the discriminant of the enum. Enums store a small tag indicating which variant is active. The check is a simple integer comparison. If the tag matches, Rust extracts the fields and binds them to the variables in the pattern. The block executes. If the tag doesn't match, the branch is skipped. The overhead is negligible.
The compiler also checks for dead code. If the value is known to be a specific variant at compile time, Rust might optimize the check away entirely.
Real-world patterns
Real code often involves nested structures. You might have an Option containing an enum, or an enum containing an Option. if let handles this gracefully by destructuring multiple layers at once.
enum Command {
Save { path: String, force: bool },
Load { path: String },
Delete { path: String },
}
struct AppState {
pending_command: Option<Command>,
}
/// Process the pending command if it's a Save operation.
fn process_save(state: &AppState) {
// Destructure Option and Command in one pattern.
// This checks for Some, then checks for Save.
if let Some(Command::Save { path, force }) = &state.pending_command {
if force {
println!("Force saving to {}", path);
} else {
println!("Saving to {}", path);
}
}
// If pending_command is None or a different variant, nothing happens.
}
The pattern Some(Command::Save { path, force }) matches an Option that is Some, and inside that, a Command that is Save. The & before state.pending_command borrows the value. This avoids moving the Command out of the struct, which would be illegal.
Convention aside: The community prefers borrowing the value directly with & rather than using ref bindings inside the pattern. if let Some(x) = &opt is clearer than if let Some(ref x) = opt. Both work, but the borrow-at-source style reads left-to-right and makes the borrow scope obvious.
Guard clauses
Sometimes matching the variant isn't enough. You need to check the data inside. if let supports guard clauses using the if keyword. This adds a boolean condition to the pattern match.
enum Event {
Key { code: u32, shift: bool },
Mouse { x: f64, y: f64 },
}
fn handle(event: Event) {
// Only handle Key events if shift is held.
// The guard `if shift` filters further after the pattern matches.
if let Event::Key { code, shift } = event if shift {
println!("Shift+Key: {}", code);
}
}
The guard runs only if the pattern matches. If event is Mouse, the guard never executes. This keeps the logic tight. You don't need a nested if inside the block.
Add the guard. Keep the logic tight.
Tuple patterns for multiple checks
Chaining if let statements creates deep indentation. Rust allows tuple patterns to check multiple values in a single if let. This flattens the structure.
fn main() {
let opt_a = Some(10);
let opt_b = Some(20);
// Tuple pattern avoids nested blocks.
// Both must be Some for the block to run.
if let (Some(a), Some(b)) = (opt_a, opt_b) {
println!("Sum: {}", a + b);
}
}
The pattern (Some(a), Some(b)) matches a tuple where both elements are Some. If either is None, the condition fails. This replaces the "arrow of doom" indentation with a single level.
Flatten the nesting. Tuple patterns save indentation.
Borrowing vs moving
A common pitfall is moving data out of the value. If the enum owns data, if let moves it by default. You can't use the original variable after the block.
enum Data {
Value(String),
}
fn main() {
let data = Data::Value("hello".to_string());
// This moves the String out of data.
if let Data::Value(s) = data {
println!("{}", s);
}
// E0382: use of moved value `data`.
// println!("{:?}", data); // Error!
}
The compiler rejects this with E0382 (use of moved value). The String moved into s. data is now invalid.
The fix is to borrow the value. Add & before the value.
fn main() {
let data = Data::Value("hello".to_string());
// Borrow the enum, don't move it.
// `s` becomes a reference to the String.
if let Data::Value(s) = &data {
println!("{}", s);
}
// data is still valid here.
println!("{:?}", data);
}
Now s is a &String. The data stays in data. You can use data after the block.
Borrow before you bind. If you need the value later, add the ampersand.
Decision matrix
Use if let when you care about exactly one variant and want to ignore the rest without writing a wildcard arm. Use match when you need to handle multiple variants with different logic or when exhaustiveness matters for safety. Use if let with &value when you need to inspect the variant without moving the data out. Use match when the compiler warns about non-exhaustive patterns and you want to force yourself to handle new variants as the enum grows. Use if let for nested destructuring of Option and Result combined with enums to reduce indentation. Use while let when you're iterating over a stream of values that might end, like a linked list or an iterator.
Pick the tool that matches your intent. If you don't care, don't write code that pretends you do.