Control Flow
if as an Expression
Section titled “if as an Expression”In Rust, if is an expression — it evaluates to a value. This means you can use it on the right-hand side of a let binding. All branches must produce the same type.
fn main() { let score = 75; let grade = if score >= 90 { "A" } else if score >= 75 { "B" } else { "C" }; println!("grade = {}", grade);}No parentheses are required around the condition, and curly braces around the body are mandatory.
What the Compiler Says: The Condition Must Be bool
Section titled “What the Compiler Says: The Condition Must Be bool”Rust has no notion of “truthy” values — no language rule turns a nonzero integer, a non-empty string, or a non-null pointer into true. The condition of an if must be a real bool, or the compiler rejects it before it ever considers what the branches return:
// @expect-error E0308fn main() { let x = 5; if x { println!("nonzero"); }}error[E0308]: mismatched types --> src/main.rs:4:8 |4 | if x { | ^ expected `bool`, found integer
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.The fix is always a real comparison, such as if x != 0. This is the same E0308 you have already seen for mismatched assignments and returns — the condition of an if is just another place the compiler checks a type against what it expects.
And the Branches Must Match Each Other
Section titled “And the Branches Must Match Each Other”Separately from the condition, the if and else bodies are two expressions that must produce the same type as each other, because the whole if has to resolve to one type when it is used as a value:
// @expect-error E0308fn main() { let x = 5; let y = if x > 0 { "positive" } else { 0 }; println!("{}", y);}error[E0308]: `if` and `else` have incompatible types --> src/main.rs:4:44 |4 | let y = if x > 0 { "positive" } else { 0 }; | ---------- ^ expected `&str`, found integer | | | expected because of this
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.An if used only as a statement (its value discarded) does not have this restriction — every branch is allowed to be () independently, which is exactly the shape of the next example.
if in a Classic Statement Form
Section titled “if in a Classic Statement Form”When you do not need the result, use if as a statement:
fn main() { let n = 7; if n % 2 == 0 { println!("even"); } else { println!("odd"); }}loop — Repeat Forever
Section titled “loop — Repeat Forever”loop runs its body indefinitely until an explicit break. Unlike other languages, Rust’s loop can return a value through break.
fn main() { let mut counter = 0; let result = loop { counter += 1; if counter == 5 { break counter * 2; // the loop evaluates to this value } }; println!("result = {}", result); // 10}Labels for Nested Loops
Section titled “Labels for Nested Loops”When loops are nested, a bare break or continue only affects the innermost one. Labels (written 'name: before the loop) let you target an outer loop directly:
fn main() { let mut found = None; 'outer: for i in 0..5 { for j in 0..5 { if i * j == 6 { found = Some((i, j)); break 'outer; } } } println!("found = {:?}", found);}break 'outer; exits both loops immediately once a match is found — without a label, break alone would only stop the inner for and the outer loop would keep going.
while — Condition-Based Loop
Section titled “while — Condition-Based Loop”while repeats as long as its condition is true. It cannot return a value via break.
fn main() { let mut n = 1; while n < 4 { print!("{} ", n); n += 1; } println!(); // newline}while let — Loop While a Pattern Matches
Section titled “while let — Loop While a Pattern Matches”while let combines while with pattern matching: it keeps looping as long as the value matches the given pattern, and stops the moment it does not — a common shape for draining a collection:
fn main() { let mut stack = vec![1, 2, 3]; while let Some(top) = stack.pop() { println!("popped: {}", top); }}.pop() returns Option<i32> — Some(value) while the Vec still has elements, None once it is empty. The loop condition is the pattern match: it runs the body while the pattern matches Some(_), and exits the instant .pop() returns None.
for — Iterate Over Collections and Ranges
Section titled “for — Iterate Over Collections and Ranges”for is the most common loop in Rust. It iterates over any type that implements the Iterator trait — including ranges and arrays.
fn main() { // Exclusive range: 1, 2, 3 for i in 1..4 { print!("{} ", i); } println!();
// Inclusive range: 1, 2, 3, 4, 5 for i in 1..=5 { print!("{} ", i); } println!();
// Iterating over an array let fruits = ["apple", "banana", "cherry"]; for fruit in fruits { print!("{} ", fruit); } println!();}Prefer for over while with a manual index whenever possible — it is safer and more expressive.
match on Integers and Ranges (a Teaser)
Section titled “match on Integers and Ranges (a Teaser)”match compares a value against a series of patterns and runs the arm for the first one that fits — patterns can be exact integers or inclusive ranges. The full power of match (enums, structs, guards, bindings) belongs to the Structs, Enums & Matching module; here is just enough to read simple dispatch logic:
fn main() { let score = 82; let grade = match score { 90..=100 => "A", 80..=89 => "B", 70..=79 => "C", _ => "F", }; println!("grade = {}", grade);}Unlike a chain of if/else if, match on an exhaustive set of patterns is checked by the compiler for completeness — the trailing _ => "F" arm here is what makes this particular match exhaustive; the next module covers what happens (and what the compiler says) when you leave one out.
let else — Pattern Match or Bail Out
Section titled “let else — Pattern Match or Bail Out”let else binds a pattern like a normal let, but if the pattern does not match, its else block runs instead — and that block must diverge (return, break, continue, or panic!), because the rest of the function assumes the pattern succeeded:
fn parse_or_default(input: &str) -> i32 { let Ok(n) = input.parse::<i32>() else { return 0; }; n * 2}
fn main() { println!("{}", parse_or_default("21")); // 42 println!("{}", parse_or_default("nope")); // 0}Compare this to while let above: both extract a value from a pattern, but while let loops while the pattern keeps matching, while let else runs once and demands the pattern match immediately, with an explicit escape hatch when it does not.
Let Chains (First Sight)
Section titled “Let Chains (First Sight)”Starting in edition 2024, you can combine multiple let pattern matches and boolean conditions with && directly inside an if (or while) — no nested if let needed:
fn main() { let a: Option<i32> = Some(5); if let Some(x) = a && x > 0 { println!("positive: {}", x); } else { println!("not positive"); }}This is a 2024-only feature. Compiling the exact same if let Some(x) = a && x > 0 under edition 2021 is rejected outright, with a message naming the edition requirement directly:
error: let chains are only allowed in Rust 2024 or later --> src/main.rs:3:8 |3 | if let Some(x) = a && x > 0 { | ^^^^^^^^^^^^^^^Before let chains, the same logic needed a nested if let with the extra condition inside it (if let Some(x) = a { if x > 0 { ... } }) or a matches!-style combination — both noticeably more nested than the 2024 version above.
fn main() { // if as an expression let score = 75; let grade = if score >= 90 { "A" } else if score >= 75 { "B" } else { "C" }; println!("grade = {}", grade);
// loop returning a value via break let mut counter = 0; let result = loop { counter += 1; if counter == 5 { break counter * 2; } }; println!("loop result = {}", result);
// while loop let mut n = 1; while n < 4 { print!("{} ", n); n += 1; } println!();
// for with a range for i in 1..=3 { print!("{} ", i); } println!();
// for over an array let nums = [10, 20, 30]; for val in nums { print!("{} ", val); } println!();}Compiling…
flowchart TD
Start{"what are you
looping over?"}
Start -->|"a collection or range"| For["for item in collection"]
Start -->|"a condition, no known count"| While["while condition"]
Start -->|"a pattern that may stop matching"| WhileLet["while let Some(x) = ..."]
Start -->|"unknown iterations,
need a value out of the loop"| Loop["loop { ... break value; }"]
For --> Body["loop body"]
While --> Body
WhileLet --> Body
Loop --> Body
Body -->|"nested loop, need to exit an outer one"| Label["label the outer loop:
'outer: for/while/loop"] Mental Model
Section titled “Mental Model”Every control-flow construct in this lesson is really answering one question: “given this condition or pattern, which block of code runs next, and does it produce a value I care about?” if and match answer it once, from a condition or a pattern, and can produce a value directly. loop, while, and for answer it repeatedly — loop is the only one that can hand a value back out (via break value), because while and for are built to stop when their condition becomes false, not when a computation is done, and those are different reasons to stop.
Trade-offs
Section titled “Trade-offs”Making if and match full expressions (instead of statements, like in C or Java) removes an entire category of “declare a variable above, assign it in every branch below” boilerplate — at the cost of a stricter rule: every branch must agree on a type, which is exactly what the compiler enforces with E0308 when they do not. Let chains buy a flatter, less-nested if let ... && condition at the cost of being a 2024-only feature — code that must also compile on an older edition (or an MSRV predating 2024’s stabilization) cannot use them yet.
Common Misconceptions
Section titled “Common Misconceptions”- “Any non-bool value can be used as a condition, like 0/1 in C or truthy/falsy values in Python and JavaScript.” Rust has no truthiness coercion at all — an
ifcondition must already be abool, full stop, which is exactly theE0308demonstrated above. - “
while letandlet elsedo the same thing.”while letrepeats its body for as long as a pattern keeps matching.let elsematches a pattern exactly once and diverges immediately (return/break/panic!) if it does not — it is for “get this value or bail,” not for looping. - “Let chains are just syntax sugar with no real edition restriction.” They are gated to edition 2024 specifically — the exact same source compiles under 2021 only after removing the chained
&&inside theif let, so this is a genuine compatibility boundary, not merely a style choice.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated loops sometimes have no reachable break at all — a loop { ... } block where every exit path was meant to be an early return, but one branch was refactored and the return quietly turned into something that does not actually leave the loop, leaving an infinite loop that only shows up under real load.
🐛 Spot the bug
fn wait_for_ready(check: impl Fn() -> bool) { loop { if check() { println!("ready!"); } }}Show the answer
This loop has no break anywhere — once check() returns true it prints "ready!" and immediately loops right back around and checks again, forever, printing "ready!" on every single iteration instead of stopping. The fix is a break in the branch that was supposed to end the wait: if check() { println!("ready!"); break; }. Every hand-written loop should be checked for a break that is actually reachable on every relevant path, not just present somewhere in the source.