← all lessons
Foundations · #3 of 13

Making Decisions and Repeating Work

if, match, and three kinds of loop — all of them produce values

A program that only runs top to bottom is a recipe with no choices and no repetition. Real software decides and it repeats — show the name if you’re logged in, add up every price in the cart.

Rust gives you the usual tools for both, plus one quiet twist: nearly all of them hand you back a value.

Real programs don’t just march through a list of instructions. They decide (“if the user is logged in, show their name; otherwise show sign in”), and they repeat (“for each item in the cart, add its price to the total”). Those two ideas — decisions and loops — are called control flow, because they control which lines of code actually run.

Rust does both in the familiar ways and one unusual one: most of its control-flow forms produce a value, so you can use them inline instead of wrapping them in extra glue code.

Expressions and statements

Before we look at if and match, two terms you’ll see throughout this curriculum:

In many languages this distinction is technical and you can ignore it. In Rust it’s load-bearing: lots of things that look like statements in other languages are actually expressions here, which lets you use them where a value is expected.

if produces a value

In a typical language, if runs one block of code or another. In Rust, if also evaluates to one block’s result or another — so you can assign it straight to a variable. Hit Run, then change temp and watch the advice change:

if as an expression editable · real rustc
Open in Playground ↗ ready

Notice there’s no semicolon after "wear shorts", "wear a coat", or "a jacket is fine". That’s deliberate: in Rust, the last expression in a block becomes the block’s value. Add a semicolon and the line turns into a statement (no value), and the assignment won’t compile.

Both branches must produce the same type. If one returned a number and the other a string, that’s a compile error — Rust will not silently convert between types to make your code work.

match is if for many cases

When you have more than two or three possibilities, chained if/else if gets ugly. Rust’s answer is match:

fn main() {
    let day = 3;
    let kind = match day {
        1 | 7 => "weekend",       // either 1 OR 7
        2..=6 => "weekday",       // any of 2, 3, 4, 5, or 6
        _ => "not a real day",    // catch-all for anything else
    };
    println!("day {day} is a {kind}");
}

Each line in the {} is an arm: a pattern, a =>, and the value that arm produces. The patterns can be specific values (1), value ranges (2..=6), several values joined with |, or _ which matches anything else.

match has a powerful property: it’s exhaustive. The compiler checks that you’ve covered every possible input. Forget a case and it refuses to build until you handle it. When we reach enums in lesson 7 — types with a fixed set of possible values — this exhaustiveness becomes a huge safety feature: add a new variant and every match on it lights up red until you’ve decided what to do.

Like if, match is an expression — the whole thing evaluates to the chosen arm’s value, which is why we could assign it to kind above.

Three kinds of loop

Loops are how you tell Rust “repeat this work.” There are three.

loop — repeat until you break, and return a value

loop { ... } runs forever until you break out of it. The elegant part: break can carry a value out, and that value becomes the value of the whole loop expression. Hit Run — it climbs n until n * n passes 100, then hands that square back:

loop that breaks with a value editable · real rustc
Open in Playground ↗ ready

That break n * n exits the loop and makes the value of the entire loop { ... } expression equal to n * n (here, 121). This is the “loops produce values” theme again — a small, distinctly Rust touch that other languages usually force you to fake with a mutable variable declared outside the loop.

while — repeat while a condition is true

fn main() {
    let mut n = 5;
    while n > 0 {
        println!("{n}");
        n -= 1;
    }
}

Each time through, the condition is checked. If it’s true, the body runs; if false, the loop ends. (while does not produce a useful value — it always evaluates to (), the empty tuple — because the compiler can’t know how many times it will run, or whether it runs at all.)

for — walk through every item in something

for is the loop you’ll reach for most. Walk a range of numbers and sum them — 1..=5 means 1 up to and including 5:

for over an inclusive range editable · real rustc
Open in Playground ↗ ready

The 1..=5 is a range with the endpoint included. Write 1..5 instead and you get 1, 2, 3, 4 — up to but not including 5. That half-open form is what you want when looping over positions in a list.

for also walks lists:

fn main() {
    let words = ["hello", "rust", "world"];
    for w in &words {
        println!("{w}");
    }
}

The & in front of words matters — without it, the loop would take ownership of the list and you couldn’t use it again afterward. That’s the subject of the next lesson; for now, write &words when looping and you’ll be fine. Anything Rust knows how to walk through can drive a for; the mechanism is the iterator trait, which we explore properly in lesson 9.

No goto — and that’s on purpose

You may have heard of goto, a command in older languages that jumps to any labelled line anywhere in the program. Rust has no goto at all. Every loop and branch is a tidy block with a clear entry and exit. That’s not an oversight — it’s the whole structured programming philosophy, and it’s the reason Rust code is easy to follow.

Portrait of Niklaus Wirth
Niklaus Wirth · 1934–2024 Swiss computer scientist who designed Pascal (1970) and a string of successors, making clean if/while/for blocks the way programmers were taught to think. His insistence on small, disciplined languages is the lineage Rust's structured control flow belongs to.

See the compiler reject a bad condition

Here’s the rule that trips up newcomers from C, Python, or JavaScript: in Rust an if (or while) condition must be a bool — literally true or false. There is no “truthiness,” no treating 0 as false or any non-zero number as true. Hit Run on this and read the error:

A condition must be a bool (this fails) editable · real rustc
Open in Playground ↗ ready

The compiler stops with error[E0308]: mismatched types, pointing at the 3 and saying expected bool, found integer. In many languages if (3) quietly counts as true and the bug ships. Rust refuses to guess what you meant: to test a number, say it out loud — if 3 > 0 { ... } — and the type lines up.

Match guards: adding a condition to a pattern

Sometimes a pattern almost fits but you want an extra check. Add a match guard — an if clause on the arm:

fn describe(n: i32) -> &'static str {
    match n {
        0 => "zero",
        n if n > 0 => "positive",
        _ => "negative",
    }
}

fn main() {
    println!("{}", describe(7));
    println!("{}", describe(-3));
    println!("{}", describe(0));
}

That n if n > 0 arm says “match any number, and check that it’s greater than zero.” Match guards are the escape hatch when pattern shape alone isn’t expressive enough.

(The &'static str return type is the type for a string literal — text compiled into the program’s data. You’ll meet it again in lesson 6.)

Why so many things in Rust are expressions, not statements

Most languages split their grammar in two: statements (do something) and expressions (produce a value), with a hard wall between them. Rust deliberately tears most of that wall down. An if, a match, a loop, even a plain { ... } block — all of them are expressions that evaluate to their final value. A semicolon is the tool that discards a value and turns an expression into a statement.

This is why a function body can end with x + 1 (no semicolon, no return) and that becomes the return value, and why let label = if c { ... } else { ... }; works. The payoff is fewer temporary variables and less duplicated assignment. The trap for beginners is the stray semicolon: write "positive"; inside an if-branch and you’ve thrown the string away, leaving () (the empty tuple) — and the compiler will complain that your branches don’t agree on a type. When an error says expected &str, found (), look for a semicolon you didn’t mean to type.

Key takeaways

Decisions and repetition are the oldest tools in programming, and Rust hands them to you sharpened: every branch is a value, every loop has one clean exit, and a vague condition is a compile error rather than a midnight bug.

You’ve now seen the shapes of Rust code. The next lesson asks the question that makes Rust unlike anything you’ve used before: when you hand a piece of data from one variable to another, who actually owns it?