Skip to content

Control Flow

Rust has the same control-flow constructs you know — if, while, for — but with two key differences: if and match are expressions that produce values, and match is exhaustive (the compiler errors if you miss a case).

In TypeScript you reach for the ternary ? : when you need a conditional value. In Rust, if itself is an expression — every branch must produce the same type.

// TypeScript
const label = score >= 60 ? "pass" : "fail";
// Rust
let label = if score >= 60 { "pass" } else { "fail" };

match in Rust is like TypeScript’s switch, but exhaustive and expression-oriented. The compiler guarantees every possible value is handled. Patterns can be ranges, literals, enums, and more.

  • loop runs forever until a break. Unlike a while true, loop can return a value via break value.
  • while works as expected.
  • for item in collection iterates over any iterable — no index arithmetic needed.
TypeScript
const score = 85;
const grade = score >= 90 ? "A" : score >= 80 ? "B" : "C";
console.log(grade); // "B"
// switch (not exhaustive by default)
switch (score) {
case 100: console.log("perfect"); break;
default: console.log("ok");
}
Rust
fn main() {
let score = 85;
// if is an expression
let grade = if score >= 90 { "A" } else if score >= 80 { "B" } else { "C" };
println!("{grade}"); // B
// loop with a break value
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 5 { break counter * 2; }
};
println!("{result}"); // 10
// for over a range
for i in 1..=3 {
println!("{i}");
}
// match is exhaustive
match score {
90..=100 => println!("A"),
80..=89 => println!("B"),
_ => println!("C or below"),
}
}

let chains — one condition, several checks

Section titled “let chains — one condition, several checks”

In TypeScript you lean on optional chaining and && to test and narrow in a single if. Rust used to make you nest: if let could only appear immediately after if, so every extra check meant another level of indentation.

Since the 2024 edition you can chain let bindings with &&, mixed with ordinary boolean conditions. Each binding is visible to the conditions after it and to the body. The let parts must be at the top level of the condition — || is still not allowed between them.

TypeScript
const cfg = loadConfig();
// TypeScript narrows through optional chaining and &&
if (cfg?.retries != null && cfg.retries > 0) {
console.log(`retrying ${cfg.retries} times`);
}
Rust
// Before 2024 — nested
if let Some(c) = &cfg {
if let Some(n) = c.retries {
if n > 0 {
println!("retrying {n} times");
}
}
}
// 2024 edition — one condition
if let Some(c) = &cfg
&& let Some(n) = c.retries
&& n > 0
{
println!("retrying {n} times");
}

Let chains are gated on the 2024 edition (they were stabilised in Rust 1.88), because they depend on the if let temporary-scope change that the edition introduced. On an older edition the compiler rejects them with “let chains are only allowed in Rust 2024 or later”.

fn sum_first_two(nums: &[u8]) -> Option<u8> {
let mut iter = nums.iter();
if let Some(first) = iter.next()
&& let Some(second) = iter.next()
{
first.checked_add(*second)
} else {
None
}
}
#[derive(Debug)]
struct Config { retries: Option<u32> }
fn main() {
println!("{:?}", sum_first_two(&[3, 4, 5]));
println!("{:?}", sum_first_two(&[9]));
let cfg = Some(Config { retries: Some(3) });
// One condition, three checks — no nesting.
if let Some(c) = &cfg
&& let Some(n) = c.retries
&& n > 0
{
println!("retrying {n} times");
}
}
fn classify(n: i32) -> &'static str {
match n {
i32::MIN..=-1 => "negative",
0 => "zero",
1..=9 => "small positive",
_ => "large positive",
}
}
fn main() {
let score = 85;
let grade = if score >= 90 { "A" } else if score >= 80 { "B" } else { "C" };
println!("Grade: {grade}");
for n in [0, -3, 5, 42] {
println!("{n} is {}", classify(n));
}
}
What happens if you forget a case in a Rust `match` expression?
How do you return a value from a `loop` in Rust?
Why do let chains require the 2024 edition?
Which of the following is valid Rust?