7.2Working with if and else

Last updated June 12, 2026

In lesson 4.7, we covered if's syntax and its party trick (being an expression). This lesson is about using it well: a subtle semantic difference that bites new programmers, a readability tool, and a scoping rule you can now predict.

else if is not a second if

These two functions look almost identical. They are not:

fn chained(n: i32) {
    if n % 2 == 0 {
        println!("divisible by 2");
    } else if n % 3 == 0 {
        println!("divisible by 3");
    }
}

fn separate(n: i32) {
    if n % 2 == 0 {
        println!("divisible by 2");
    }
    if n % 3 == 0 {
        println!("divisible by 3");
    }
}

fn main() {
    chained(6);
    println!("---");
    separate(6);
}
divisible by 2
---
divisible by 2
divisible by 3

An if / else if chain is checked top to bottom, and the first true condition wins; the rest of the chain isn't even evaluated. Separate if statements are each evaluated, every time. For 6, which is divisible by both, the chain reports only one fact and the pair reports two.

Neither is "the right one." They answer different questions: the chain asks "which one of these cases is it?", the separate statements ask "which of these independent things are true?" New programmers often write a chain out of habit when the conditions aren't actually exclusive, and then puzzle over the missing output. Decide which question you're asking first.

When every arm returns, else is decoration

Here's a chain where every arm ends the function:

fn size_label(centimeters: u32) -> &'static str {
    if centimeters < 160 {
        return "small";
    } else if centimeters < 185 {
        return "medium";
    } else {
        return "large";
    }
}

Since return leaves the function on the spot, control never reaches an else after a returning arm. So the chain can be flattened:

fn size_label(centimeters: u32) -> &'static str {
    if centimeters < 160 {
        return "small";
    }
    if centimeters < 185 {
        return "medium";
    }
    "large"
}

Each if acts as a gate: handle one case, get out, and the code below only ever sees what's left. This early return style keeps functions shallow, and it reads especially well when the gates are error checks at the top ("if the input is bad, return early; the rest of the function gets clean input"). You'll see this shape constantly in real Rust.

That said, for this particular function, lesson 4.7's expression form beats both versions (one if chain as the tail expression, no return at all). Reach for early returns when the arms do things; reach for the expression form when the arms are things.

Best practice

Use early returns when they simplify a function's logic, particularly for rejecting bad cases up front. Use the if-as-expression form when every arm exists only to produce a value.

Flattening nested ifs

Conditions can nest, and nesting two levels deep is usually fine. But new programmers often nest where a logical operator (lesson 6.5) says the same thing flatter:

// nested (harder to follow)
if age >= 16 {
    if has_permit {
        println!("may drive");
    }
}

// flat (same behavior)
if age >= 16 && has_permit {
    println!("may drive");
}

If you find yourself three levels deep in ifs, stop and look for an &&, an ||, or an early return that flattens the staircase. Future-you, scanning the function at speed, will be grateful.

In C-family languages, nested ifs have a second hazard, famous enough to have a name: the dangling else. Since braces are optional there, this snippet is a trap:

// C, not Rust:
if (x >= 0)
    if (x <= 20)
        printf("in range");
else
    printf("negative");   // looks attached to the first if...

The indentation says the else belongs to the outer if. The compiler disagrees: an else attaches to the nearest unmatched if, so it belongs to the inner one, and the program prints "negative" for x = 21. Rust's mandatory braces make this ambiguity unwritable; there's no way to even pose the question, because every else sits against a closing brace that names its owner. The goto-fail story from lesson 4.7 and the dangling else are the same moral: optional braces were the bug, and Rust deleted them.

Values born in an arm die at the brace

One scoping consequence worth seeing once, because the error will otherwise surprise you. A variable defined inside an if arm is local to that arm's block, just like any block (lesson 1.11):

fn main() {
    let score = 87;
    if score >= 60 {
        let verdict = "pass";
    }
    println!("{verdict}");
}
error[E0425]: cannot find value `verdict` in this scope
 --> src/main.rs:6:16
  |
6 |     println!("{verdict}");
  |                ^^^^^^^
  |
help: the binding `verdict` is available in a different scope in the same function
 --> src/main.rs:4:13
  |
4 |         let verdict = "pass";
  |             ^^^^^^^

By the time the println! runs, verdict no longer exists; it lived and died inside the braces. And notice how precise the help is: the compiler knows the name exists somewhere, points at the exact let, and says which scope it's trapped in. It has diagnosed your design, not just your typo.

You already know the fix, and it isn't declaring a mutable variable before the if and assigning in each arm. It's the expression form: let verdict = if score >= 60 { "pass" } else { "fail" };. The value escapes the arms because the if itself delivers it.

Quiz time

Question #1

What does this print?

fn main() {
    let n = 12;
    if n % 2 == 0 {
        println!("even");
    } else if n > 10 {
        println!("big");
    }
    if n > 10 {
        println!("big, checked separately");
    }
}
Show solution
even
big, checked separately

The chain stops at its first true condition (n % 2 == 0), so the chained n > 10 is never tested. The separate if afterward is independent and runs on its own merits.

Question #2

Flatten this function so it contains no nested if and no else, without changing its behavior:

fn access_level(age: u32, is_member: bool) -> &'static str {
    if age >= 18 {
        if is_member {
            return "full access";
        } else {
            return "guest access";
        }
    } else {
        return "no access";
    }
}
Show solution
fn access_level(age: u32, is_member: bool) -> &'static str {
    if age < 18 {
        return "no access";
    }
    if is_member {
        return "full access";
    }
    "guest access"
}

The under-18 gate is handled first and returns early, so everything below it can assume age >= 18. Note the first condition flipped from >= to <: early-return style often inverts conditions so the rejection leaves first. (An if-as-expression version is also a fine answer.)

Question #3

Predict the compiler's reaction, precisely:

fn main() {
    let logged_in = true;
    if logged_in {
        let user = "salvo";
        println!("hello, {user}");
    }
    println!("goodbye, {user}");
}
Show solution

E0425: cannot find value user in this scope, pointing at the second println!. user is local to the if arm's block and is gone after the closing brace. The first println! is fine; it's inside the block where user lives.

Next: the chapter's headliner. if decides between two paths; match handles any number of them, and brings a guarantee no if chain can make.