Right, so you’ve met the if statement. It’s fine. It does its job. But in Rust, we don’t just have statements; we have expressions. And this is where things get interesting and, frankly, a little bit brilliant. An if expression in Rust is like a Swiss Army knife that also makes a decent espresso—it’s far more capable than its counterparts in other languages.

The core idea is stupidly simple yet profoundly powerful: an if block can evaluate to a value. This isn’t just a fancy way to assign a variable; it fundamentally changes how you structure your code, letting you lean into Rust’s ownership and type system in a way that feels natural.

The Basic Syntax: It’s an Expression, I Promise

Forget what you know from C or Python. Here, if isn’t just a control flow construct that does things; it’s one that is a thing.

let condition = true;
// This isn't just an if-statement setting a variable...
// This is an *if-expression* being bound to a variable.
let number = if condition {
    5
} else {
    6
};

println!("The number is: {}", number); // Prints "The number is: 5"

See that? The entire if block evaluates to 5, and that value gets assigned to number. This is the beating heart of the feature. Notice the lack of semicolons inside the blocks? That’s crucial. In Rust, a semicolon turns an expression into a statement, which then returns the unit type (). If you add a semicolon, you’ve killed the expression and your code will refuse to compile.

let number = if condition {
    5; // This is now a statement, returning `()`
} else {
    6; // Ditto
};
// This will cause a compiler error: mismatched types. Expected integer, found `()`

The compiler will call you out immediately. It’s one of my favorite things—it won’t let you be ambiguous.

All Arms Must Be of the Same Type

This is the big one. The compiler is not a mind reader. It needs to know the type of number at compile time. It can’t say, “Well, if the condition is true, it’s an integer, but if it’s false, it’s a string.” That would be chaos. All arms of the if expression must evaluate to a value of the same type.

// This will NOT compile.
let culprit = if condition {
    42 // This is an i32...
} else {
    "forty-two" // ...and this is a &str. Nope.
};

The error message is fantastic: if and else have incompatible types. It’s direct and saves you from a runtime mess. This strictness is a feature, not a bug. It forces you to be explicit and handle all your cases properly.

Using if in Places You Might Not Expect

Because it’s an expression, you can slot it in anywhere a value is expected. This leads to some incredibly concise and readable code.

// A classic: providing a default value if an Option is None.
let maybe_value: Option<i32> = Some(10);
let value = if let Some(x) = maybe_value { x } else { 0 }; // Much cleaner than a match here.

// Even right in the middle of a function call!
println!(
    "The result is: {}",
    if some_complex_condition() { "Success" } else { "Failure" }
);

It turns what would be a multi-line imperative chore into a single, declarative line. You’re describing what the value should be, not the steps to compute it.

The Pitfall: The Curse of the Semicolon

I mentioned it before, but it’s the most common trip-up, so it deserves its own heading. You must vigilantly avoid semicolons in the final expression of each block.

fn create_status(success: bool) -> String {
    if success {
        String::from("Operation succeeded"); // Oops. Semicolon. This returns `()`.
    } else {
        String::from("Operation failed"); // Also returns `()`.
    }
} // This function is now declared to return String, but actually returns `()`. Compiler error!

The fix is simple: lose the semicolons. The last expression in a block is its value. It feels weird at first if you’re coming from other languages, but you’ll soon prefer it. It’s code that flows.

else if Chaining: It Just Works

And of course, this being a practical language, you can chain these together. It works exactly as you’d hope, evaluating to the value of the first condition that holds true.

let temperature = 25;
let description = if temperature > 30 {
    "hot"
} else if temperature > 15 {
    "pleasant"
} else {
    "cold"
}; // description is now "pleasant"

It’s a cleaner, more expression-oriented alternative to a bulky match statement when you’re dealing with range-like conditions.

This is one of those features that seems minor until you use it. Then you go back to a language that doesn’t have it and feel like you’re trying to write a novel with your hands tied behind your back. It encourages a functional, expression-based style that makes your code more predictable and easier to reason about. It’s Rust saying, “Why wouldn’t it work this way?” And honestly, it’s right.