if let and while let
Rust’s match expression is powerful, but it can feel heavy when you only care about one pattern out of many possible ones. if let and while let are concise forms built directly on top of match that let you test whether a value matches a single pattern, bind its inner data, and run code only in that case. They show up constantly when working with Option<T>, Result<T, E>, and custom enums, so understanding them well is essential to writing idiomatic Rust.
Overview: How if let and while let Work
To see why if let exists, think about what a plain match looks like when you only care about one variant. Suppose you have an Option<i32> and you only want to do something when it is Some, ignoring the None case entirely. With match you are forced to handle every arm, even if the other arm is just _ => {} (do nothing). That is extra ceremony for a very common situation: "if this value matches this one shape, bind its contents and use them."
if let is exactly that sentence turned into syntax. if let PATTERN = EXPRESSION tries to match EXPRESSION against PATTERN once. If it matches, any variables named in the pattern are bound and the following block runs. If it does not match, control falls through to an optional else block (or simply continues past the whole expression). Under the hood the compiler rewrites this into a match with two arms — the pattern you wrote, and a wildcard _ arm that does nothing (or runs the else block). Because if let is really a match in disguise, all the same ownership and borrowing rules apply: if the pattern binds a value by value (not by reference) and the matched type does not implement Copy, that value is moved into the binding, exactly as it would be moved into a match arm.
while let applies the same idea to loops. while let PATTERN = EXPRESSION checks the pattern once per iteration: as long as EXPRESSION matches PATTERN, the loop body runs; the first time it fails to match, the loop stops. This is the standard way to drain a stack, walk an iterator manually, or process items from a queue until it reports "nothing left" — typically by returning None. Mentally, while let Some(x) = some_call() is a loop that keeps calling some_call() and exits the instant it gets a None back, which matters because some_call() is re-evaluated fresh on every pass, not just once at the top.
Syntax
The general forms are shown below. Neither is a full statement on its own outside a function body — both are expressions that appear where an if or while would.
if let PATTERN = EXPRESSION {
// runs if EXPRESSION matches PATTERN;
// variables in PATTERN are bound here
} else {
// runs if it does not match (optional)
}
while let PATTERN = EXPRESSION {
// runs each time EXPRESSION matches PATTERN;
// loop exits the first time it does not match
}
- PATTERN — any pattern you could use in a
matcharm:Some(x),Ok(v),Err(e), a specific enum variant, a tuple pattern, and so on. - EXPRESSION — any expression that produces a value of the type the pattern expects, such as a variable, a function call, or a method call like
.pop()or.next(). - else block — only valid on
if let, not onwhile let. It runs when the pattern does not match. - You can chain
else if letfor a few alternative patterns, similar toelse if, but if you find yourself checking more than two or three variants, a realmatchis usually clearer because it is checked for exhaustiveness by the compiler.
Examples
1. if let vs. a full match
The simplest case: you have an Option<i32> and want to print the number only if it is present.
fn main() {
let some_number: Option = Some(7);
if let Some(n) = some_number {
println!("Got a number: {}", n);
} else {
println!("No number found");
}
}
Output:
Got a number: 7
some_number is Option<i32>, and i32 implements Copy, so the value is copied into n rather than moved — some_number would still be usable afterward if we needed it. The else branch exists purely to show the alternative; it never runs here because the value is Some(7).
2. if let with a custom enum
if let works with any enum, not just Option and Result. Here it pulls the state name out of one specific variant.
enum Coin {
Penny,
Nickel,
Dime,
Quarter(String),
}
fn main() {
let coin = Coin::Quarter(String::from("Alaska"));
if let Coin::Quarter(state) = coin {
println!("Quarter from {}", state);
} else {
println!("Not a quarter");
}
}
Output:
Quarter from Alaska
Because coin holds a String (not Copy), matching it by value moves coin into the if let. That is fine here since we never use coin again afterward — but it is exactly the situation the first Common Mistake below explores in detail.
3. while let draining a stack
Vec::pop removes and returns the last element as Option<T>, returning None once the vector is empty — a perfect fit for while let.
fn main() {
let mut tasks = vec![
String::from("write tests"),
String::from("fix bug"),
String::from("deploy"),
];
while let Some(task) = tasks.pop() {
println!("Processing: {}", task);
}
println!("All tasks done. Remaining: {}", tasks.len());
}
Output:
Processing: deploy
Processing: fix bug
Processing: write tests
All tasks done. Remaining: 0
pop() removes from the end of the vector, so items come out in last-in-first-out order: deploy first, write tests last. Each call to tasks.pop() is re-evaluated on every loop pass. Once the vector is empty, pop() returns None, the pattern Some(task) fails to match, and the loop ends on its own — no manual counter or break needed.
4. A more realistic example: looking up a value in a HashMap
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert("Alice", 90);
scores.insert("Bob", 85);
if let Some(score) = scores.get("Alice") {
println!("Alice's score: {}", score);
} else {
println!("Alice not found");
}
}
Output:
Alice's score: 90
HashMap::get returns Option<&V> — a reference wrapped in Option, not the value itself — precisely so that looking a key up does not remove it or transfer ownership out of the map. if let Some(score) = scores.get("Alice") binds score as &i32, which is exactly what a lookup like this should hand back: a peek, not a move.
How It Works Step by Step
When the compiler sees if let Some(n) = some_number { ... } else { ... }, it desugars it to something equivalent to:
match some_number {
Some(n) => { /* if-let block */ },
_ => { /* else block */ },
}
That is the whole trick: if let is a match with exactly one real arm and a catch-all. This is also why the borrow/move rules are identical to match — the compiler is quite literally building a match behind the scenes.
while let Some(top) = stack.pop() { ... } desugars similarly, but wrapped in a loop:
loop {
match stack.pop() {
Some(top) => { /* loop body */ },
_ => break,
}
}
Each iteration re-runs the expression (stack.pop()), matches the fresh result against the pattern, runs the body if it matches, and breaks the moment it does not. Understanding this desugaring answers most "why doesn’t this compile" questions: if you would not be allowed to write the equivalent match, you are not allowed to write the if let or while let version either.
Common Mistakes
Mistake 1: using a value after if let has moved it
If the matched value’s type does not implement Copy, matching it by value moves it into the pattern’s bindings — after that, the original variable is gone.
fn main() {
let name: Option = Some(String::from("Ferris"));
if let Some(n) = name {
println!("Hello, {}", n);
}
println!("{:?}", name); // error: use of moved value: `name`
}
The compiler rejects this because Some(n) = name moves the String out of name and into n; once the if let block ends, n (and the string it owned) is dropped, and name itself was already emptied by the move, so it can no longer be used. The fix is to match on a reference to the option instead of the option itself, so nothing is moved:
fn main() {
let name: Option = Some(String::from("Ferris"));
if let Some(n) = &name {
println!("Hello, {}", n);
}
println!("{:?}", name);
}
Output:
Hello, Ferris
Some("Ferris")
Matching &name gives a pattern of type Option<&String>, so n is bound as &String — a borrow, not an owner. name is untouched and still valid on the next line.
Mistake 2: forgetting mut on the collection used with while let
Vec::pop needs a mutable reference to remove an element, so the variable holding the vector must be declared mut.
fn main() {
let stack = vec![1, 2, 3]; // missing `mut`
while let Some(top) = stack.pop() {
println!("{}", top);
}
}
This fails with cannot borrow \"stack\" as mutable, as it is not declared as mutable, because pop has the signature fn pop(&mut self) -> Option<T> and an immutable binding cannot produce a &mut reference. The fix is simply to add mut:
fn main() {
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("{}", top);
}
println!("Stack is now empty: {:?}", stack);
}
Output:
3
2
1
Stack is now empty: []
This is a common trip-up for beginners because a plain let stack = vec![...] compiles fine on its own — the error only appears once you try to call a mutating method like pop, push, or insert on it.
Best Practices
- Reach for
if letwhen you only need to act on one pattern and can ignore or trivially handle the rest; reach for a fullmatchwhen you need every variant handled and want the compiler to enforce exhaustiveness. - Add an
elsetoif letwhenever the "didn’t match" case needs its own logic — it keeps both outcomes in one expression instead of a separate flag variable. - Match on
&value(or call.as_ref()) insideif let/while letwhenever you still need the original variable afterward, to avoid an unwanted move. - Remember a
while letloop ends the instant the pattern stops matching — make sure the expression’s state actually changes each iteration (e.g.pop(),next()), or you risk an infinite loop. - Avoid stacking more than two or three
else if letpatterns; past that, amatchreads more clearly and catches missed variants at compile time. - Don’t worry about performance —
if letandwhile letcompile down to the samematch/loopcode you would write by hand, with zero extra runtime cost.
Practice Exercises
- Write a function
fn print_double(value: Option<i32>)that usesif letto print double the number whenvalueisSome, and prints"no value"when it isNone. Call it once withSome(4)and once withNone. Expected output forSome(4):8. - Build a
Vec<char>from the characters of a word, then usewhile letwith.pop()to remove characters one at a time and append each to aString, producing the word reversed. Print the result. - Given
let result: Result<i32, String> = Err(String::from("not found"));, useif let Ok(v) = resultwith anelsebranch to print either the success value or the error message stored insideErr. Hint: you’ll need to match onErr(msg)in theelse, which means anelse if letrather than a plainelse.
Summary
if let PATTERN = EXPRESSIONtests one pattern and binds its contents if it matches, with an optionalelsefor the non-matching case.while let PATTERN = EXPRESSIONloops as long as the pattern keeps matching, and stops automatically the first time it doesn’t.- Both are syntactic sugar for
match—if letdesugars to a two-armedmatch, andwhile letdesugars toloopplus amatchthatbreaks on the non-matching arm. - Because they are built on
match, the same ownership and borrowing rules apply: matching by value moves non-Copydata, so match on a reference (&value) when you need the original afterward. - Use
if let/while letfor single-pattern convenience; use a fullmatchwhen you need every variant handled and compiler-enforced exhaustiveness. while let Some(x) = iterator_or_pop_call()is the idiomatic way to drain a stack or walk items until you getNone.
