if let and while let in Rust
Rust's match expression is powerful — but sometimes you only care about one pattern and want to ignore everything else. Writing a full match just for that feels like overkill. That's where if let and while let come in: they give you the power of pattern matching with much less ceremony.
The Problem: Verbose match for a Single Pattern
Suppose you have an Option and only want to act when the value is Some. A full match looks like this:
let favourite_colour: Option<&str> = Some("blue");
match favourite_colour {
Some(colour) => println!("Your favourite colour is {}", colour),
None => (), // do nothing — just an empty arm to satisfy the compiler
}The None => () arm exists purely to satisfy the compiler's exhaustiveness requirement. It adds noise
without adding meaning. if let removes that noise entirely.
if let Syntax
if let combines a pattern and an expression into one construct. If the expression matches the pattern, the block runs and any bindings are available inside it. If it does not match, the block is skipped.
let favourite_colour: Option<&str> = Some("blue");
if let Some(colour) = favourite_colour {
println!("Your favourite colour is {}", colour);
}Read it as: "if favourite_colour matches the pattern Some(colour), run the block with colour bound to the inner value." No extra None arm needed.
if let with else
Attach an else block that runs when the pattern does not match — similar to the None arm in a match:
let config_max: Option<u8> = Some(3);
if let Some(max) = config_max {
println!("The maximum is configured to be {}", max);
} else {
println!("No maximum configured, using default.");
}Chaining with else if and else if let
if let chains naturally with else if and additional else if let arms, letting you handle several patterns or conditions in sequence without nesting:
let number: Option<u32> = Some(7);
let is_even = true;
if let Some(n) = number {
println!("Got a number: {}", n);
} else if is_even {
println!("No number, but the flag says even.");
} else {
println!("No number and flag is false.");
}Unlike match, the compiler does NOT check that if let chains cover all cases. That is a deliberate trade-off: you get brevity, but you lose the exhaustiveness guarantee.
if let with Enums (not just Option)
if let works with any enum, not only Option. Here is an example with a custom Coin enum that carries data inside one of its variants:
enum Coin {
Penny,
Quarter(String), // carries a state name
}
let coin = Coin::Quarter(String::from("Alaska"));
if let Coin::Quarter(state) = coin {
println!("State quarter from {}!", state);
} else {
println!("Not a quarter.");
}while let: Loop While a Pattern Matches
while let is the looping counterpart to if let. The loop body runs as long as the expression keeps matching the pattern. The moment the match fails, the loop exits cleanly — no manual break needed.
The classic use-case is draining a stack:
let mut stack = Vec::new();
stack.push(1);
stack.push(2);
stack.push(3);
while let Some(top) = stack.pop() {
println!("{}", top);
}Vec::pop() returns Option<T>. When the vector is empty it returns None, the pattern fails to match, and the loop exits automatically. No manual length checks or break statements required.
while let with Iterators
You can also use while let to manually drive an iterator. This is mostly equivalent to a for loop, but useful when you need extra control mid-loop (for example, skipping iter.next() on certain iterations):
let data = vec![10, 20, 30];
let mut iter = data.iter();
while let Some(value) = iter.next() {
println!("value = {}", value);
}let else (Rust 1.65+)
let else is the newest member of this family. It binds a pattern in a regular let statement and provides a diverging else block — one that must return, break, continue, or panic! — for when the pattern fails to match.
fn process(input: &str) -> u32 {
let Ok(number) = input.trim().parse::<u32>() else {
println!("Not a valid number, returning 0.");
return 0;
};
// 'number' is bound here, in the surrounding scope — not locked inside a block
number * 2
}
println!("{}", process("21")); // 42
println!("{}", process("abc")); // prints message, returns 0The key difference from if let: the binding from let else lives in the surrounding scope, not inside a nested block. This avoids rightward indentation drift that accumulates with successive guard clauses.
The matches! Macro
When you only need a bool result — no binding — the matches! macro is the most concise option:
let val: Option<i32> = Some(42);
// verbose — a full match just to produce a bool
let is_some = match val {
Some(_) => true,
None => false,
};
// concise — matches! macro
let is_some = matches!(val, Some(_));
// also supports match guards
let is_positive = matches!(val, Some(n) if n > 0);
println!("{}", is_some); // true
println!("{}", is_positive); // trueWhen to Use if let vs match
Scenario | Best tool |
|---|---|
Handle one variant, ignore everything else |
|
Need exhaustive coverage of every variant |
|
Loop until a pattern stops matching |
|
Early-return guard at the top of a function |
|
Boolean check with no binding needed |
|
Multiple patterns, each with different logic |
|
Practical Example: Parsing Optional Config Values
Here is a realistic snippet showing if let, while let, and let else working together to process optional command-line arguments:
fn apply_config(port: Option<&str>, retries: Option<&str>) {
// let else: bail early if the port string is not a valid u16
let port_str = port.unwrap_or("8080");
let Ok(port_num) = port_str.parse::<u16>() else {
eprintln!("Invalid port '{}', aborting.", port_str);
return;
};
// if let: optional value — only act when present
if let Some(r) = retries {
if let Ok(n) = r.parse::<u8>() {
println!("Retries set to {}", n);
} else {
println!("Invalid retry count '{}', ignoring.", r);
}
}
println!("Server will start on port {}", port_num);
}
apply_config(Some("3000"), Some("5"));
apply_config(Some("bad"), None);