if let: one arm, and move on¶
Level: 101 · newcomer
One line: if let is a match with the arm you did not care about deleted — which is exactly what you want, and exactly what stops the compiler from checking you covered everything.
The empty arm¶
Once a function returns an Option, the compiler wants you to say what happens in both cases. Very often the honest answer for one of them is nothing at all:
match first(&list) {
Some(x) => println!("The first element is {x}"),
None => {} // an arm written to satisfy the compiler
}
That empty arm is noise. It says nothing, and a reader still has to read it to find that out. if let is the same code with it removed:
Read it as "if this value matches this pattern, bind the names in the pattern and run the block." If it does not match, nothing happens and execution carries straight on. There is no runtime difference between the two versions — this is sugar, and it compiles to the same match.
It really is a match, so every pattern works¶
Some(x) is the one you meet first, but nothing about if let is Option-specific. Anything you can write as a match arm can go on the left:
if let Some(&Review { author, stars }) = reviews.first() { … } // destructure through the reference
if let Some(Review { author, stars: 5 }) = reviews.first() { … } // a literal is a condition, not a binding
if let Some((rank, label)) = pair { … } // tuples, nesting, `_`, all as usual
And when the other case does deserve a line, else is right there — at which point you have re-invented the two-arm match and either spelling is fine.
The trade, stated plainly¶
A match over an enum is exhaustive: the compiler refuses to build until every variant is accounted for. That is not pedantry, it is the single most useful safety property in the language, and it pays out on the day someone adds a variant — every match in the codebase that now has a hole stops compiling and a human decides what to do about each one.
if let opts out of that check for one expression. Add a variant, and an if let that used to see everything now silently sees less:
match -> 3 rated, 1 blank, 1 unreadable = 5 of 5 marks
if let -> 3 rated, and 2 marks silently unaccounted for
Both of those are the same data. Neither is a bug in Rust; the second is a decision someone made when they wrote if let instead of match, possibly years before the variant that made it wrong existed.
So the rule of thumb: if let when you genuinely mean "this one shape, and I do not care about the rest" — a config value that is present or absent, a first element, a parse that worked. match when the cases are a set you are dividing up, especially over an enum you or your dependencies control. If you would want to be told about a new variant, do not use if let.
Its three relatives¶
if let is one member of a family, and the other three are the answers to "yes, but what about…":
| You want | Write | Why |
|---|---|---|
| one shape, nothing otherwise | if let Some(x) = v { … } |
the empty arm deleted |
| one shape, or leave this scope | let Some(x) = v else { return; }; |
the happy path stays unindented |
| repeat while the shape keeps matching | while let Some(x) = stack.pop() { … } |
the None is the loop's exit condition |
just a bool |
matches!(v, Some(n) if n > 4) |
if the answer is a bool, ask for a bool |
let … else is the one worth internalising early. It inverts if let: instead of indenting the work you came to do, it deals with the failure and leaves the binding in scope at the left margin for the whole rest of the function. The else block must diverge — return, break, continue, panic! — which is what lets the compiler treat the binding as unconditionally present afterwards.
matches! deserves its own mention because the alternative is so tempting: a mut flag declared above an if let whose body sets it to true. That is four lines for a boolean expression.
Two things that will bite you¶
It binds by moving. The pattern takes ownership unless you tell it not to, so this does not compile:
error[E0382]: borrow of partially moved value: `name`
3 | if let Some(n) = name {
| - value partially moved here
6 | println!("{name:?}");
| ^^^^ value borrowed here after partial move
help: borrow this binding in the pattern to avoid moving the value
3 | if let Some(ref n) = name {
The compiler's suggestion works, but the form you will actually write is if let Some(n) = &name — match on a reference and the bindings become references too. .as_ref() and .as_deref() do the same job when you need to chain. Reaching for .clone() here is the common reflex and almost always the wrong one.
Edition 2024 changed when the scrutinee's temporary dies. If the value you match on builds a temporary, edition 2021 kept that temporary alive to the end of the whole if let — including the else block. Edition 2024 drops it before the else runs:
Nobody cares until the temporary is a lock guard, at which point the 2021 order means an else block that takes the same lock deadlocks, and the 2024 order means it does not. This is one of the edition's few real behaviour changes, and it is the reason to prefer 2024 for new code rather than a style preference.
Edition 2024: if let chains¶
Since Rust 1.88, and only in edition 2024, you can chain let bindings and conditions in one head, each binding visible to the next:
Under edition 2021 that is a hard error ("let chains are only allowed in Rust 2024 or later") and the same logic has to be written as a staircase of nested if lets with the condition at the bottom. If you are reading older Rust and wondering why it is shaped like that, this is why.
If you are coming from another language¶
- Python — the walrus in
if (x := d.get(k)) is not None:is the same instinct, and 3.10'smatch/caseis the same shape again. What changes: the binding exists only inside the branch where the pattern held, and inside it the value cannot be the missing one. There is no path on whichxisNoneand you forgot to check. - ABAP — this is
READ TABLE lt INTO ls ... IF sy-subrc = 0.welded into a single statement. What changes: you cannot separate the read from the check, cannot forget the check, and cannot touchlson the path where the read failed — the work area does not exist there. The cost is the flip side of the same weld:if letalso drops the exhaustiveness theCASEit replaces would have given you, which is the trade above.
Practice¶
The arm you deleted. Write an exhaustive match over a two-variant Status enum, and a one-arm if let that prints a banner only for the certified case. Then add a third variant to the enum and recompile.
Before you add the variant, also write the version that uses if let … else as a stand-in for match. Adding the variant makes the compiler come and find the match; the if let keeps compiling and starts telling a lie. That difference is the whole trade.
Solution
if_let_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: what `if let` stops checking.
//!
//! rustc --edition 2024 if_let_kata.rs -o /tmp/ilk && /tmp/ilk
#[derive(Debug)]
enum Status {
Counting,
Certified { winner: &'static str },
// Added later — the whole point of the exercise. A `match` on Status stops
// compiling the moment this line appears. Every `if let` keeps compiling.
Contested { by: &'static str },
}
/// Exhaustive: the compiler made me come back and handle Contested.
fn announce(s: &Status) -> String {
match s {
Status::Counting => "still counting".to_string(),
Status::Certified { winner } => format!("{winner} has won"),
Status::Contested { by } => format!("result challenged by {by}"),
}
}
/// One arm, and silence for everything else — which is right here, because
/// "print a banner only when we have a winner" genuinely has one case.
fn banner(s: &Status) {
if let Status::Certified { winner } = s {
println!(" ★ {winner} ★");
}
}
/// The same shape, used wrongly: this one *looks* like it covers the states,
/// and quietly says nothing at all for Contested.
fn misleading(s: &Status) -> String {
if let Status::Certified { winner } = s {
format!("{winner} has won")
} else {
"still counting".to_string() // a lie for Contested
}
}
fn main() {
let states = [
Status::Counting,
Status::Certified { winner: "Ada" },
Status::Contested { by: "Ben" },
];
println!("match — the compiler made me handle the new variant:");
for s in &states {
println!(" {:<28} -> {}", format!("{s:?}"), announce(s));
}
println!("\nif let, used for what it is good at (one case, silence otherwise):");
for s in &states {
banner(s);
}
println!(" (only one banner printed, and that was the intent)");
println!("\nif let/else, used as a substitute for match:");
for s in &states {
println!(" {:<28} -> {}", format!("{s:?}"), misleading(s));
}
println!(" The last line is wrong, and nothing warned. That is the");
println!(" exhaustiveness you traded away for the deleted arm.");
println!("\nlet-else keeps the happy path at the left margin:");
for s in &states {
println!(" {}", certified_or_bail(s));
}
}
fn certified_or_bail(s: &Status) -> String {
let Status::Certified { winner } = s else {
return " (not certified — nothing to print)".to_string();
};
format!(" certified: {winner}")
}
Verified output of if_let_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
match — the compiler made me handle the new variant:
Counting -> still counting
Certified { winner: "Ada" } -> Ada has won
Contested { by: "Ben" } -> result challenged by Ben
if let, used for what it is good at (one case, silence otherwise):
★ Ada ★
(only one banner printed, and that was the intent)
if let/else, used as a substitute for match:
Counting -> still counting
Certified { winner: "Ada" } -> Ada has won
Contested { by: "Ben" } -> still counting
The last line is wrong, and nothing warned. That is the
exhaustiveness you traded away for the deleted arm.
let-else keeps the happy path at the left margin:
(not certified — nothing to print)
certified: Ada
(not certified — nothing to print)
The verified output¶
Verified output of if_let.rs — regenerated by tools/run_examples.py, never hand-typed.
──── Step 1: The empty arm you did not want to write
match -> The first element is 10
if let -> The first element is 10
Nothing printed for the empty list — `if let` just does not run.
Same behaviour, same cost, one fewer arm to read.
──── Step 2: It is a `match`, so any pattern works — and it can take an `else`
struct pattern -> Ada gave 5
literal in it -> Ada gave the maximum
tuple pattern -> rank 1 is the first
else -> nothing there, and this arm says so
──── Step 3: The trade: `if let` is not exhaustive
match -> 3 rated, 1 blank, 1 unreadable = 5 of 5 marks
if let -> 3 rated, and 2 marks silently unaccounted for
The day `Unreadable` was added to the enum, the `match` stopped
compiling and someone had to decide what to do about it.
The `if let` compiled fine and quietly went on under-counting.
That is the price of the deleted arm — pay it deliberately.
──── Step 4: `let … else`: bind, or leave — the guard clause
populated -> widget leads with 12
empty -> no products at all
`if let` indents the happy path; `let … else` keeps it flat and
sends the failure out of the function. The `else` block must
diverge — return, break, continue, or panic — so what follows it
can rely on the binding existing.
──── Step 5: `while let`: keep going until the pattern stops matching
popped 30, 2 left
popped 20, 1 left
popped 10, 0 left
`pop()` is a partial function returning Option, so the loop's
exit condition IS the None — no length check, no index, no
off-by-one available to get wrong.
──── Step 6: When you only want a bool: `matches!` and `is_some_and`
matches!(mark, Mark::Rated(_)) -> true
matches!(mark, Mark::Rated(n) if n>4) -> false
stars.is_some_and(|n| n > 3) -> true
`if let` with an empty body and a flag set inside it is a smell.
If the answer is a bool, ask for a bool.
──── Step 7: Edition 2024: `if let` chains
chained -> comparing alpha against beta
chained -> no comparison: one of the two is absent
Two `if let`s and a condition in one head, each binding visible
to the next. Stable since Rust 1.88, and only in edition 2024 —
before that this was a staircase of nested `if let`s.
──── Step 8: It binds by MOVING, unless you ask otherwise
&name -> borrowed Ada
name after -> Some("Ada") (still ours)
as_deref() -> Ada as a &str, no clone
moved -> took ownership of Ada
Reaching for `.clone()` here is the usual reflex and the usual
mistake: `&opt`, `.as_ref()`, or `.as_deref()` cost nothing.
──── Step 9: Edition 2024 moved when the scrutinee's temporary dies
dropped the temporary the scrutinee built
the else block runs — and the temporary is already gone
In edition 2021 the drop line came AFTER the else block: the
temporary lived to the end of the whole `if let`. If that
temporary is a lock guard, the else block that tries to take
the same lock deadlocks. Edition 2024 fixed it by dropping
before the else — one of the edition's few behaviour changes.
Run it yourself:
See also¶
OptionvsResult— where these patterns get used, andlet … elsein its natural habitatwhile let— the same head in a loop, where the pattern failing is how it ends- Partial functions — why
pop()andfirst()return anOptionforwhile letandif letto consume Optionis a one-item collection — the other way to handle one arm:map,and_then,is_some_and- The Rust Reference on
if let↗ andletstatements with anelse↗
Po polsku¶
if let to match, z którego usunięto gałąź, na której i tak nic się nie działo — owo None => {}, napisane wyłącznie po to, żeby zadowolić kompilator. Różnicy w czasie działania nie ma żadnej: to lukier składniowy, kompilujący się do dokładnie tego samego matcha. W czytaniu warto od razu ustawić sobie jedną rzecz: znak = w if let Some(x) = v nie jest przypisaniem. Po lewej stronie stoi wzorzec, a całość czyta się „jeżeli ta wartość pasuje do tego wzorca, zwiąż nazwy i wykonaj blok”. Skoro pod spodem jest dopasowanie wzorców, działa każdy wzorzec: destrukturyzacja struktur, krotki, zagnieżdżenia — a literał wpisany we wzorzec (score: 5) jest warunkiem, nie wiązaniem.
Płaci się za to jedną rzeczą i lepiej wiedzieć o niej wcześniej niż później. match nad wyliczeniem jest wyczerpujący (exhaustive): kompilator nie zbuduje programu, dopóki nie obsłużysz wszystkich wariantów, i to jest najużyteczniejsza właściwość bezpieczeństwa w całym języku, bo wypłaca się w dniu, w którym ktoś dokłada nowy wariant. if let z tej kontroli rezygnuje — dla jednego wyrażenia. Ćwiczenie z tej strony pokazuje różnicę jednym wierszem: po dopisaniu wariantu Contested wersja z match przestała się kompilować i człowiek musiał zdecydować, co z nią zrobić, a wersja z if let … else kompilowała się dalej i zaczęła odpowiadać „still counting” na stan, który wcale nie jest liczeniem. Reguła kciuka: if let, gdy naprawdę chodzi o jeden kształt, a reszta cię nie obchodzi (wartość jest albo jej nie ma, pierwszy element, udany parse); match, gdy dzielisz zbiór przypadków — zwłaszcza nad wyliczeniem, które kontrolujesz ty albo twoje zależności. Jeżeli chciałbyś, żeby ktoś ci powiedział o nowym wariancie, nie pisz if let.
Trzy pokrewne konstrukcje odpowiadają na trzy różne „tak, ale co, jeśli…”:
let … else— odwracaif let: zamiast wcinać pracę, po którą przyszedłeś, załatwia porażkę i zostawia wiązanie na lewym marginesie do końca funkcji. Blokelsemusi przerwać przepływ sterowania (diverge) —return,break,continue,panic!— i właśnie dlatego dalej kompilator może traktować wiązanie jako pewne;while let— gdyNonejest warunkiem zakończenia pętli, a nie sytuacją wyjątkową;matches!— gdy odpowiedzią ma być zwykłybool. Kusząca alternatywa, czyli mutowalna flaga zadeklarowana nadif leti ustawiana w środku natrue, to cztery wiersze na jedno wyrażenie logiczne.
Na koniec dwie rzeczy, które ugryzą. Wzorzec przenosi własność, więc if let Some(n) = name zabiera wartość i późniejsze użycie name kończy się na E0382. Kompilator podpowiada Some(ref n), ale w praktyce pisze się = &name — dopasowanie do referencji sprawia, że wiązania też są referencjami; do łączenia wywołań służą .as_ref() i .as_deref(). Odruch sięgnięcia po .clone() jest tutaj najczęstszy i prawie zawsze zły. Druga rzecz to prawdziwa zmiana zachowania w edycji 2024: wartość tymczasowa zbudowana przez wyrażenie, po którym dopasowujesz, ginie przed blokiem else, a w edycji 2021 dopiero po nim. Nikogo to nie obchodzi, dopóki tą wartością nie jest strażnik blokady (lock guard) — wtedy kolejność z 2021 oznacza zakleszczenie w else, który próbuje wziąć tę samą blokadę. Z edycją 2024 wiąże się też druga nowość: łańcuchy if let … && let … (od Rusta 1.88) działają wyłącznie tam, więc jeśli w starszym kodzie widzisz schodki zagnieżdżonych if let z warunkiem na samym dole, to jest właśnie ten powód.
Szukaj po polsku: dopasowanie wzorców w Ruscie · wyliczenia w Ruscie · rust if let vs match exhaustive · rust let else · rust let chains edition 2024