When if returns a value
You're writing a function to categorize a user's subscription tier. In Python or JavaScript, you write an if block, assign a variable inside the branches, and hope you covered every path. Rust gives you a sharper tool: if isn't just a control flow statement. It's an expression. It produces a value.
This small shift lets you write cleaner assignments and eliminates a whole class of "variable not initialized" bugs before they happen. You stop writing code that mutates state inside branches and start writing code that computes results. The compiler enforces that every branch produces a value of the same type. If you miss a branch or mix types, the code won't compile.
Expression versus statement
In many languages, if is a statement. A statement performs an action but doesn't return anything. It's like shouting a command: "Turn left!" Rust treats if as an expression. An expression evaluates to a value. It's like asking a question and getting an answer back.
When you write let x = if condition { A } else { B }, the if block calculates the result and hands it to x. The braces around A and B define blocks that evaluate to values. The last expression in a block becomes the block's value, provided there's no trailing semicolon. This behavior is consistent across Rust. Functions return values via expressions. Blocks return values via expressions. if follows the same rule.
Treat the if block as a function that returns a value. If it doesn't return something, it doesn't belong there.
Minimal example
/// Demonstrates if/else as an expression returning a value.
fn main() {
let score = 85;
// The if block evaluates to a string literal.
// The else block must also evaluate to the same type.
let grade = if score >= 90 {
"A"
} else if score >= 80 {
"B"
} else {
"C"
};
// `grade` is assigned the result of the expression.
println!("Grade: {}", grade);
}
What happens at compile and runtime
At runtime, Rust checks the condition. If score >= 90 is true, it enters the first block, evaluates "A", and stops. That value becomes the result of the whole if expression. If false, it checks the else if. If that's true, it returns "B". If neither is true, it falls through to else and returns "C".
The compiler does heavy lifting before runtime. It verifies that every possible path returns a value. It checks that "A", "B", and "C" are all the same type (&str). If you accidentally returned an integer in one branch, you'd get a type mismatch error. The compiler also ensures you don't have a dangling if without an else when you're using it as an expression. You can't assign a variable to an if that might not produce a value. The type system requires a result for every path.
Type inference and branch order
Rust infers the type of the if expression from the first branch it encounters. If your first branch returns a String, every other branch must also return a String. If the second branch returns a &str, the compiler complains. This order dependency can be surprising. The fix is usually to coerce types explicitly or reorder branches so the most specific type comes first.
In practice, returning &str literals is common. Ensure all branches return &str, not a mix of String and &str. If one branch needs to return a computed string, use .to_string() on the literals in other branches to unify the type. The compiler will guide you with E0308 (mismatched types) if the types diverge.
Realistic example
/// Calculates shipping cost based on weight and destination.
/// Returns the cost as a floating-point number.
fn calculate_shipping(weight: f64, is_international: bool) -> f64 {
// Base rate depends on weight brackets.
// The if expression returns the base rate directly.
let base_rate = if weight > 10.0 {
25.0
} else if weight > 5.0 {
15.0
} else {
5.0
};
// Apply multiplier based on destination.
// Another if expression handles the multiplier.
let multiplier = if is_international {
2.5
} else {
1.0
};
// Combine results.
base_rate * multiplier
}
fn main() {
let cost = calculate_shipping(7.5, true);
println!("Shipping cost: ${:.2}", cost);
}
Passing values directly
You don't need a variable to capture the result. You can pass the if expression directly into a function call. This keeps the code tight. process(if valid { data } else { default }) works perfectly. The expression evaluates, produces a value, and that value gets passed. This is idiomatic Rust. It avoids intermediate variables that clutter the scope.
/// Shows passing an if expression directly to a function.
fn log_status(is_active: bool) {
// The if expression evaluates to a string slice.
// The result is passed directly to println.
println!("Status: {}", if is_active { "Online" } else { "Offline" });
}
Pitfalls and compiler errors
The most common trap is the trailing semicolon. In Rust, a semicolon suppresses the value. If you write if x { 1; } else { 2 }, the first branch returns (), not 1. The compiler rejects this with E0308 (mismatched types) because () doesn't match i32. Remove the semicolon to return the value. Watch your semicolons. A stray punctuation mark can turn your integer into unit type and break the build.
Type mismatch is the second frequent error. Every branch must return the same type. You can't return String in one branch and &str in another without conversion. The compiler will flag this immediately. If you need to return owned strings from some branches and borrowed strings from others, convert the borrowed strings to owned using .to_string() or .into().
Missing else is another error. When using if as an expression, you must provide an else. You can't have a bare if that might return nothing. The compiler requires a value for every path. If you only need side effects and don't care about a return value, use an if statement with a semicolon at the end, or use if let for pattern matching.
Readability limits
Three levels of nesting is usually the limit before an if expression becomes hard to scan. If you find yourself writing if ... { if ... { if ... } }, stop. Extract the inner logic into a helper function or switch to match. match often handles complex branching more clearly because it lays out all cases vertically. Use if expressions for simple decisions. Use match for complex routing.
Convention aside: cargo fmt formats every file the same way. Don't argue style; argue logic. If the formatter aligns your braces, trust it. The community values consistent formatting over personal preference.
Decision matrix
Use if expressions when you need to assign a value based on a condition and want to avoid temporary variables. Use if expressions when returning a value from a function based on logic, keeping the return inline with the calculation. Use match when you have more than two or three branches, or when you need to destructure values like enums or tuples. Use if statements (with semicolons) when you only need side effects, like printing or modifying a mutable variable, and don't need a return value. Use helper functions when nesting exceeds two levels and readability suffers. Use bool methods like .is_some() or .contains() when you just need a true/false check and don't need to transform the value.
Pick the tool that matches the complexity. Simple condition? if. Complex branching? match.