Control Flow
How to direct program execution in Rust with if expressions, loops, and conditional pattern matching using if let
Every program makes decisions and repeats actions. Rust provides these capabilities through control flow constructs that are both expressive and safe. Unlike many languages where conditionals and loops are purely statements, several of Rust's control flow tools are expressions — they evaluate to a value. This design encourages concise, functional-style code while the compiler checks for consistency and correctness at every branch.
if Expressions
The if keyword in Rust does more than check a boolean condition. It is an expression, which means the entire if block can return a value that gets assigned to a variable. This eliminates the need for separate declarations followed by conditional assignments and keeps related logic bundled together.
The condition inside if must be a bool. Rust does not implicitly convert numbers or other types to boolean truthiness the way C or JavaScript does. If you write if 1, the compiler rejects it. This prevents subtle bugs where a non-zero integer accidentally evaluates to true.
let temperature = 22;
let weather = if temperature > 30 {
"hot"
} else if temperature > 20 {
"warm"
} else {
"cold"
};
println!("The weather is {}.", weather);
When temperature is 22, the output is The weather is warm. The condition temperature > 20 matches, so the "warm" expression becomes the value of the entire if block, which is then assigned to weather. Every branch evaluates to a &str, and all branches must produce the same type. If one branch returned a string slice and another returned an integer, the compiler would report a type mismatch. This strictness prevents runtime surprises where a variable unexpectedly changes type.
Type Mismatch in `if` Branches:
If the branches of an if expression do not return the same concrete type, the code will not compile. For example, a branch returning 5 and another returning "five" causes a type error because Rust needs to know the type of the resulting value at compile time. This is a common mistake for developers coming from dynamically typed languages.
The else if and else arms are optional. If you omit an else and no condition matches, an if used as an expression still needs to produce a value. In that case, the compiler treats the if as returning (), the unit type, which is a valid value but rarely what you want. When if is used purely as a statement — for its side effects — no else is required, and the branches need not agree on a type because no assignment happens.
let score = 85;
if score >= 90 {
println!("Excellent");
} else if score >= 75 {
println!("Good");
}
This if block does not produce a value; it prints to the console. The branches are statements, so type uniformity does not apply.
How beginners should think about if expressions: Imagine a small junction box. Each path leads to a label that must be of the same shape — say, all strings or all numbers. The box itself can be placed directly where you need that label, like inside a let assignment, without any extra wiring.
Repetition with Loops
Rust has three loop constructs: loop, while, and for. Each serves a distinct purpose, and knowing which one to use makes intent clearer and reduces the risk of off-by-one or infinite-loop bugs.
The loop Keyword — Infinite Until Explicitly Stopped
loop creates an unconditional infinite loop. It runs until a break statement terminates it. This is useful when the exit condition is complex, depends on multiple factors, or should be evaluated in the middle of the body rather than at the top.
loop can return a value through break. The value follows the break keyword and becomes the result of the entire loop expression. This pattern is common in retry logic, game loops, or scenarios where a computation runs until a successful result is found.
let mut attempts = 0;
let result = loop {
attempts += 1;
if attempts > 3 {
break attempts * 10;
}
println!("Attempt {} failed, retrying...", attempts);
};
println!("Final result: {}", result);
The loop runs three times. On the fourth iteration, attempts becomes 4, which triggers the break with the value 40. The loop expression evaluates to 40, assigned to result. The output prints three failure messages and then Final result: 40.
A loop without a break would run forever. If you accidentally write an infinite loop in a function that expects a return value, the compiler might not always warn you, but the program will hang.
Forgetting the `break` Value Type:
When loop is used as an expression to produce a value, the break statement must include the value. If you write break; without a value, the loop returns (), the unit type. If the surrounding code expects an integer, the compiler will complain about a type mismatch. Always double-check that break carries the intended value when the loop is on the right side of a let.
Nested loops can use labels to specify which loop a break or continue targets. A label is a single quote followed by a name, placed before the loop.
'outer: for x in 1..4 {
for y in 1..4 {
if x * y > 5 {
println!("Breaking outer loop at x={}, y={}", x, y);
break 'outer;
}
println!("({}, {})", x, y);
}
}
The inner loop breaks the outer loop when the product exceeds 5, terminating both loops immediately. Without the label, break would only exit the inner loop. Labels add clarity when dealing with deeply nested structures, but they should be used sparingly to keep code readable.
The while Loop — Condition at the Top
A while loop checks a condition before each iteration. If the condition is true, the body runs; otherwise, the loop exits. This is suitable when the number of iterations is unknown but governed by a single boolean condition.
let mut count = 3;
while count > 0 {
println!("Countdown: {}", count);
count -= 1;
}
println!("Liftoff!");
Output:
Countdown: 3
Countdown: 2
Countdown: 1
Liftoff!
The condition count > 0 is checked before each iteration. Once count becomes 0, the loop stops. Unlike loop, while cannot produce a value on its own — it always evaluates to (). If you need a value, combine while with a mutable variable updated inside the body, or consider using loop with a break value instead.
A common idiomatic pattern is while let, which keeps looping as long as a pattern matches a value. This is frequently used with iterators or optional values when you want to process items until a collection is empty.
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("Popped: {}", top);
}
This prints each popped element: 3, 2, 1. The pop() method returns Some(value) while the vector has elements, and None when empty. while let runs until the pattern fails. It is a compact alternative to a manual loop with explicit pattern matching.
`while` and Infinite Loops:
A while true loop also runs indefinitely, but the compiler may optimize loop better because it knows the condition is unconditionally true. When an infinite loop with an internal break is needed, prefer loop over while true for clearer intent and potential performance benefits.
The for Loop — Iterating Over Collections and Ranges
The for loop in Rust works exclusively with iterators. It eliminates the need for manual index management and avoids entire categories of bugs like off-by-one errors or out-of-bounds indexing. Every for loop consumes an iterator and binds each item to a variable.
The most basic use is iterating over a range. Ranges are created with start..end (exclusive end) or start..=end (inclusive end).
for i in 0..4 {
println!("i = {}", i);
}
Output:
i = 0
i = 1
i = 2
i = 3
The range 0..4 produces the values 0, 1, 2, 3. The variable i takes each value in turn. There is no index variable to declare, no condition to check, and no increment step — the iterator handles everything safely.
Iterating over a collection like a vector is just as straightforward. By default, a for loop over a collection consumes the collection, transferring ownership of each element. To iterate by reference, use &collection for immutable references or &mut collection for mutable references.
let numbers = vec![10, 20, 30];
for num in &numbers {
println!("Number: {}", num);
}
println!("Vector still owned: {:?}", numbers);
The &numbers borrows the vector immutably, so numbers remains available after the loop. If you need the index alongside the value, use the .enumerate() method on an iterator:
for (idx, val) in numbers.iter().enumerate() {
println!("Index {}: {}", idx, val);
}
`for` Loops Prevent Index Bugs:
If you come from languages that use classic three-part for loops with manual indices, Rust's iterator-based approach guarantees you will never accidentally access an invalid index or modify a collection while iterating. The borrow checker enforces these rules at compile time. If your loop compiles, it is safe from those runtime errors.
Conditional Pattern Matching with if let
The if let construct provides a concise way to match a single pattern while ignoring all other possibilities. It is syntactic sugar for a match expression that only has one arm of interest and a catch-all _ => {} arm. Use if let when you want to run code only if a value matches a specific pattern, without the ceremony of a full match.
The syntax is:
if let PATTERN = EXPRESSION {
// body
}
If the pattern matches, the body executes. If it doesn't, nothing happens. This is most frequently used with Option and Result types to handle the Some or Ok case while discarding the None or Err case.
let maybe_name: Option<&str> = Some("Alice");
if let Some(name) = maybe_name {
println!("Hello, {}!", name);
}
Since maybe_name is Some("Alice"), the pattern Some(name) matches, binding "Alice" to name. The output is Hello, Alice!. If maybe_name were None, the body would be skipped entirely — no error, no panic.
You can also combine if let with an else block to handle the non-matching case:
fn describe_number(n: Option<i32>) {
if let Some(value) = n {
if value > 0 {
println!("Positive {}", value);
} else {
println!("Zero or negative {}", value);
}
} else {
println!("No number provided");
}
}
This keeps logic compact. Without if let, you would need a full match with two arms, which is more verbose when the None case requires only a simple action.
if let is not limited to Option — it works with any enum or destructuring pattern. However, for complex multi-arm matching, match remains the clearer and more expressive tool. The upcoming chapter on pattern matching will cover match and advanced patterns in depth.
`if let` Does Not Check Exhaustiveness:
Unlike match, the compiler does not require if let to handle every possible variant. If you add a new variant to an enum, if let expressions that match only the old variants will silently skip the new one. Use match when you need the compiler to enforce that all cases are covered, especially in critical logic paths.
Summary
The control flow constructs in Rust — if, loops, and if let — share a common thread: they enforce correctness at compile time. if demands consistent types across branches. for eliminates index errors by working through iterators. if let offers lightweight pattern matching without the ceremony of a full match, but at the cost of exhaustiveness checks.
The single most important insight from this section is that many of these constructs are expressions, not just statements. This property allows you to embed decision-making and repetition directly into value assignments, making code denser and more readable once you adjust to it. When you write let result = if ... or let result = loop { break ... }, you are using Rust's expression-oriented design to collapse boilerplate.