`if` Expressions

Essential
Last updated: Tags: Rust

Prerequisites

Rust’s if is not just a conditional statement — it is an expression that produces a value. This one property changes how you write assignments, function bodies, and conditional logic throughout the language.

The basic shape

The condition must be a bool. Rust never coerces integers, pointers, or other types to boolean, so if n where n is an integer is a compile error — write if n != 0 instead.

if temperature > 100.0 {
    println!("too hot");
} else if temperature < 0.0 {
    println!("too cold");
} else {
    println!("just right");
}

if as a value-producing expression

Because if is an expression, you can assign its result directly to a binding:

let label = if n > 0 { "positive" } else { "non-positive" };

All branches must produce the same type. Mismatched arms are a compile error:

let x = if flag { 1 } else { "text" }; // error: expected integer, found `&str`

Each branch is a block, and a block’s value is its last expression (no trailing semicolon). A trailing semicolon turns the expression into a statement and makes the block return (), which causes a type error when the declared type is not ():

let x = if flag { 1 } else { 2; }; // error: expected i32, found ()
//                             ^ semicolon discards the 2

Omitting else

When if is used as a statement — its value is thrown away — you may omit the else branch. Both the if-branch and the implicit else-absence return (), so the types are consistent:

if verbose {
    println!("debug: starting");
}

If you try to use such a form as an expression, the compiler insists on an else:

let x = if flag { 1 }; // error: `if` may be missing an `else` clause

Early returns — the guard clause

Functions in Rust use return only for early exits. A common pattern is the guard clause: check a precondition at the top and bail immediately when it fails:

fn parse_positive(s: &str) -> Option<u32> {
    if s.is_empty() {
        return None;
    }
    let n: u32 = s.parse().ok()?;
    if n == 0 {
        return None;
    }
    Some(n)
}

Guard clauses keep the happy path un-nested and easy to scan. Inside loops, the same idiom uses continue (skip this iteration) or break (exit the loop) instead of return.

if let

if let pairs a pattern with a conditional: the block runs only when the pattern matches.

let config: Option<&str> = Some("debug");

if let Some(level) = config {
    println!("log level: {level}");
}

This is shorthand for a match with one meaningful arm. Add else to handle the non-matching case:

if let Some(level) = config {
    println!("log level: {level}");
} else {
    println!("using default log level");
}

if let is an expression like regular if, so you can assign its result to a binding as long as both branches agree on a type. You will see if let used frequently with Option, Result, and any enum with a payload — any situation where you care about exactly one variant. Patterns in general are covered in Pattern Matching.