Conditionals #

Conditionals in Rust feel familiar on the surface — there’s if, there’s else — but two things set them apart fundamentally from other languages. First, if and match in Rust are expressions, not statements: both produce a value that can be assigned directly to a variable. Second, match in Rust is far more powerful than switch in other languages — it supports destructuring, guard conditions, name bindings, and most importantly, it’s exhaustive: the compiler rejects programs that don’t handle every possible pattern. Combined, these produce conditional code that’s more expressive, safer, and more concise than chained if-else.

The if Expression #

if in Rust requires a condition of exactly type bool — there’s no implicit conversion from integers or pointers to booleans like in C:

fn main() {
    let suhu = 32;

    // Basic form
    if suhu > 30 {
        println!("Hot");
    }

    // With else
    if suhu > 30 {
        println!("Hot");
    } else {
        println!("Cool");
    }

    // ANTI-PATTERN: non-bool condition (not valid in Rust)
    let angka = 1;
    // if angka { ... }  // error[E0308]: expected `bool`, found integer
    // if angka != 0 { ... }  // CORRECT: explicit

    // ANTI-PATTERN: no need for == true or == false
    let aktif = true;
    if aktif == true { println!("This is redundant"); }  // ✗
    if aktif { println!("More idiomatic"); }         // ✓
    if !aktif { println!("Inactive"); }            // ✓
}

if as an Expression #

This is one of the features that most often surprises developers coming from other languages: if in Rust is an expression that produces a value. The entire if-else block can sit on the right-hand side of an assignment.

fn main() {
    let skor = 85;

    // if as an expression — replaces the ternary ?: operator from other languages
    let kelulusan = if skor >= 75 { "Lulus" } else { "Tidak Lulus" };
    println!("Status: {}", kelulusan);

    // Can be longer than one expression — the last value is the result
    let kategori = if skor >= 90 {
        "Sangat Baik"
    } else if skor >= 80 {
        "Baik"
    } else if skor >= 70 {
        "Cukup"
    } else {
        "Kurang"
    };
    println!("Kategori: {}", kategori);

    // ANTI-PATTERN: different types in each branch
    // let nilai = if skor >= 75 { "Lulus" } else { 0 };
    // error[E0308]: `if` and `else` have incompatible types
    // All branches MUST return the same type

    // CORRECT: make sure the types are consistent
    let pesan = if skor >= 75 {
        format!("Passed with a score of {}", skor)
    } else {
        format!("Failed, score {} is below 75", skor)
    };
    println!("{}", pesan);
}

Chained if-else if #

fn klasifikasi_bmi(bmi: f64) -> &'static str {
    if bmi < 18.5 {
        "Underweight"
    } else if bmi < 25.0 {
        "Normal"
    } else if bmi < 30.0 {
        "Overweight"
    } else {
        "Obese"
    }
}

fn main() {
    let berat = 70.0_f64;
    let tinggi = 1.75_f64;
    let bmi = berat / (tinggi * tinggi);

    println!("BMI: {:.1}{}", bmi, klasifikasi_bmi(bmi));

    // ANTI-PATTERN: too many else-ifs for patterns match can handle
    // If you're checking the same value repeatedly, match is more appropriate
}

match — Exhaustive Pattern Matching #

match is the most powerful construct in Rust for conditionals. It forces you to handle every possibility — if any pattern isn’t handled, the code won’t compile. This eliminates the “forgot to handle case X” class of bugs entirely.

fn main() {
    let angka = 7;

    match angka {
        1 => println!("One"),
        2 => println!("Two"),
        3 => println!("Three"),
        _ => println!("Something else"),  // wildcard — required if not exhaustive
    }

    // match is also an expression — produces a value
    let deskripsi = match angka {
        1 => "satu",
        2 => "dua",
        3 => "tiga",
        _ => "banyak",
    };
    println!("Number {} = {}", angka, deskripsi);
}

Compound Patterns, Ranges, and Guards #

match is far more flexible than switch in other languages — a single arm can handle several patterns at once, value ranges, and additional conditions:

fn main() {
    let kode_http = 404;

    let pesan = match kode_http {
        // Single pattern
        200 => "OK",
        201 => "Created",

        // Multiple patterns with |
        301 | 302 => "Redirect",

        // Inclusive range
        400..=499 => "Client Error",
        500..=599 => "Server Error",

        // Wildcard
        _ => "Unknown",
    };
    println!("HTTP {}: {}", kode_http, pesan);

    // Guard condition — an extra filter after the pattern
    let bilangan = -5i32;

    let kategori = match bilangan {
        n if n < 0  => "negatif",
        0           => "nol",
        n if n % 2 == 0 => "genap positif",
        _           => "ganjil positif",
    };
    println!("{} is {}", bilangan, kategori);
}

Binding with @ #

The @ operator lets you capture the value that matches a pattern while also giving it a name to use in the arm body:

fn main() {
    let usia = 17;

    let keterangan = match usia {
        // Capture the value matching the range into `n`
        n @ 0..=12  => format!("Child, age {}", n),
        n @ 13..=17 => format!("Teenager, age {}", n),
        n @ 18..=64 => format!("Adult, age {}", n),
        n           => format!("Senior, age {}", n),
    };
    println!("{}", keterangan);

    // Without @, you have to repeat the condition inside the arm
    // ANTI-PATTERN:
    let keterangan2 = match usia {
        13..=17 => format!("Teenager, age {}", usia),  // must mention usia again
        _ => String::from("Other"),
    };
    println!("{}", keterangan2);
}

match with Enums #

match and enums work together very closely in Rust — this is the pattern you’ll encounter most often in idiomatic Rust code.

#[derive(Debug)]
enum Arah {
    Utara,
    Selatan,
    Timur,
    Barat,
}

#[derive(Debug)]
enum Perintah {
    Gerak(Arah),
    Berhenti,
    Percepat { kecepatan: u32 },
    Putar(f64),  // degrees
}

fn proses(perintah: &Perintah) {
    match perintah {
        // Variant without data
        Perintah::Berhenti => println!("Stop"),

        // Variant with tuple data — destructuring
        Perintah::Gerak(arah) => println!("Moving {:?}", arah),
        Perintah::Putar(derajat) => println!("Turn {} degrees", derajat),

        // Variant with named fields — destructuring
        Perintah::Percepat { kecepatan } => println!("Accelerate to {} km/h", kecepatan),
    }
}

fn main() {
    let perintah_list = vec![
        Perintah::Gerak(Arah::Utara),
        Perintah::Percepat { kecepatan: 60 },
        Perintah::Putar(90.0),
        Perintah::Berhenti,
    ];

    for p in &perintah_list {
        proses(p);
    }
}

match with Option and Result #

The two enums most often matched are Option<T> and Result<T, E>:

fn bagi(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 { None } else { Some(a / b) }
}

fn parse_angka(s: &str) -> Result<i32, std::num::ParseIntError> {
    s.trim().parse()
}

fn main() {
    // match Option
    match bagi(10.0, 3.0) {
        Some(hasil) => println!("Result: {:.4}", hasil),
        None => println!("Cannot divide by zero"),
    }

    // match Result
    match parse_angka("42") {
        Ok(n) => println!("Parsed: {}", n),
        Err(e) => println!("Error: {}", e),
    }

    // Nested match — handling Option<Result<...>>
    let input = Some("123");
    match input {
        Some(s) => match parse_angka(s) {
            Ok(n) => println!("Valid number: {}", n),
            Err(_) => println!("Not a valid number"),
        },
        None => println!("No input"),
    }
}

Destructuring Tuples and Structs in match #

struct Titik {
    x: i32,
    y: i32,
}

fn main() {
    // Tuple destructuring
    let koordinat = (3, -5);
    let kuadran = match koordinat {
        (0, 0)           => "Origin",
        (x, 0) if x > 0 => "Positive X axis",
        (0, y) if y > 0 => "Positive Y axis",
        (x, y) if x > 0 && y > 0 => "Quadrant I",
        (x, y) if x < 0 && y > 0 => "Quadrant II",
        (x, y) if x < 0 && y < 0 => "Quadrant III",
        _                => "Quadrant IV",
    };
    println!("{:?}{}", koordinat, kuadran);

    // Struct destructuring
    let titik = Titik { x: 0, y: 7 };
    match titik {
        Titik { x: 0, y } => println!("On the Y axis, y = {}", y),
        Titik { x, y: 0 } => println!("On the X axis, x = {}", x),
        Titik { x, y }    => println!("Another point: ({}, {})", x, y),
    }
}

if let — Matching a Single Pattern #

if let is a concise way to handle one pattern from match without writing out every other possibility. It fits when you only care about a single case and want to ignore the rest.

fn main() {
    let angka: Option<i32> = Some(42);

    // ANTI-PATTERN: a redundant match just for the one case you care about
    match angka {
        Some(n) => println!("There's a number: {}", n),
        None => {},  // nothing to do — why write it?
    }

    // CORRECT: if let is more concise for this case
    if let Some(n) = angka {
        println!("There's a number: {}", n);
    }

    // if let with else
    if let Some(n) = angka {
        println!("Value: {}", n);
    } else {
        println!("No value");
    }

    // Nested if let for complex types
    let data: Result<Option<i32>, &str> = Ok(Some(100));

    if let Ok(Some(nilai)) = data {
        println!("Success with value: {}", nilai);
    }

    // if let with a custom enum
    #[derive(Debug)]
    enum Status { Aktif(String), Nonaktif }

    let status = Status::Aktif(String::from("premium"));

    if let Status::Aktif(tipe) = &status {
        println!("Active status: {}", tipe);
    }
}

Chained if let / else if let #

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

    if let Some("debug") = config {
        println!("Debug mode active");
    } else if let Some("release") = config {
        println!("Release mode");
    } else if let Some(mode) = config {
        println!("Unknown mode: {}", mode);
    } else {
        println!("No configuration");
    }
}

while let — Looping with a Pattern #

while let repeats the loop as long as the pattern matches. It’s most often used to process collections that yield Option until exhausted:

fn main() {
    // Process a stack until empty
    let mut tumpukan = vec![1, 2, 3, 4, 5];

    while let Some(atas) = tumpukan.pop() {
        println!("Popped: {}", atas);
    }
    println!("Stack is empty");

    // while let with a manual iterator
    let data = vec!["apel", "mangga", "jeruk"];
    let mut iter = data.iter();

    while let Some(buah) = iter.next() {
        println!("Fruit: {}", buah);
    }

    // ANTI-PATTERN: a loop with explicit match for cases while let fits
    let mut v = vec![10, 20, 30];
    loop {
        match v.pop() {
            Some(n) => println!("{}", n),
            None => break,
        }
    }
    // CORRECT: equivalent but more concise
    let mut v = vec![10, 20, 30];
    while let Some(n) = v.pop() {
        println!("{}", n);
    }
}

let-else — Destructuring with a Fallback #

Since Rust 1.65, there’s a new construct: let-else. It lets you destructure in a regular let, but with an else block that runs if the pattern doesn’t match. The else block must always diverge — usually with return, break, continue, or panic!.

fn proses_input(input: &str) -> Option<u32> {
    // ANTI-PATTERN: deeply nested if let
    if let Ok(angka) = input.trim().parse::<u32>() {
        if angka > 0 {
            println!("Valid input: {}", angka);
            return Some(angka);
        }
    }
    None

    // CORRECT: let-else flattens the code flow — the happy path isn't nested
}

fn proses_dengan_let_else(input: &str) -> Option<u32> {
    // If parsing fails, return None immediately — the main flow stays flat
    let Ok(angka) = input.trim().parse::<u32>() else {
        return None;
    };

    // If the number is zero, return None immediately
    let angka = if angka > 0 {
        angka
    } else {
        return None;
    };

    println!("Valid input: {}", angka);
    Some(angka)
}

fn main() {
    println!("{:?}", proses_dengan_let_else("42"));   // Some(42)
    println!("{:?}", proses_dengan_let_else("abc"));  // None
    println!("{:?}", proses_dengan_let_else("0"));    // None
}

let-else is very useful for input validation at the start of a function — every invalid condition is rejected in the first lines without nesting the main flow:

struct Pengguna {
    nama: String,
    usia: u8,
}

fn buat_pengguna(nama: &str, usia_str: &str) -> Option<Pengguna> {
    // Sequential validation with let-else — the flow stays flat
    let nama = nama.trim();
    let Ok(usia) = usia_str.trim().parse::<u8>() else {
        eprintln!("Invalid age: {}", usia_str);
        return None;
    };
    let (18..=120) = usia else {  // unstable version, but the concept is the same
        eprintln!("Age must be between 18-120");
        return None;
    };

    Some(Pengguna {
        nama: nama.to_string(),
        usia,
    })
}

When to Choose if vs match #

Both constructs can often be used interchangeably, but there are situations where one is clearly more appropriate:

flowchart TD
    Q{What is being compared?}
    Q --> A{Boolean conditions\nor complex\nnumeric ranges?}
    Q --> B{Values of an enum\nor several\ndiscrete values?}
    Q --> C{A single pattern\nfrom Option/Result?}

    A -- Yes --> D[Use if / else if\nMore natural for\ncomplex boolean conditions]
    B -- Yes --> E[Use match\nExhaustive, safer\nsupports destructuring]
    C -- Yes --> F[Use if let\nMore concise than\nmatch for one case]

    E --> G{Are the patterns\nvery numerous and nested?}
    G -- Yes --> H[Consider refactoring\ninto several small functions]
    G -- No --> I[Straight match is already right]
fn main() {
    let nilai = 85;
    let status: Option<String> = Some(String::from("aktif"));
    let hasil: Result<i32, &str> = Ok(42);

    // Numeric conditions with complex logic → if is more natural
    if nilai >= 90 && nilai <= 100 {
        println!("Perfect");
    } else if nilai >= 75 {
        println!("Passed");
    } else {
        println!("Failed");
    }

    // Many discrete values from an enum/integer → match is better
    let grade = match nilai {
        90..=100 => 'A',
        80..=89  => 'B',
        70..=79  => 'C',
        60..=69  => 'D',
        _        => 'E',
    };
    println!("Grade: {}", grade);

    // A single case from an Option → if let is most concise
    if let Some(s) = &status {
        println!("Status: {}", s);
    }

    // All cases from a Result → match for completeness
    match hasil {
        Ok(n) => println!("Ok: {}", n),
        Err(e) => println!("Err: {}", e),
    }
}

Summary #

  • if and match are expressions — both produce a value that can be assigned to a variable. No ternary ?: operator needed like in other languages.
  • An if condition must be exactly bool — no implicit conversion from integers or pointers. if angka isn’t valid Rust code.
  • match is exhaustive — the compiler rejects code that doesn’t handle every possibility. Use _ as a wildcard to catch irrelevant cases.
  • All match arms must have the same type — if match is used as an expression, every arm must produce exactly the same type.
  • match patterns support single values, multiple patterns with |, ranges with ..=, guards with if, bindings with @, and tuple/struct/enum destructuring.
  • if let for a single pattern — more concise than match when you only care about one case and want to ignore the rest.
  • while let for pattern-based loops — most idiomatic for processing a stack or iterator that yields Option until exhausted.
  • let-else for early validation — allows destructuring in a let line with a fallback that exits the function immediately, keeping the main flow flat without nesting.
  • Use match for enums — exhaustiveness checking ensures you never forget to handle a new variant added to the enum.

← Previous: Operators   Next: Loops →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact