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 #
ifandmatchare expressions — both produce a value that can be assigned to a variable. No ternary?:operator needed like in other languages.- An
ifcondition must be exactlybool— no implicit conversion from integers or pointers.if angkaisn’t valid Rust code.matchis exhaustive — the compiler rejects code that doesn’t handle every possibility. Use_as a wildcard to catch irrelevant cases.- All
matcharms must have the same type — ifmatchis used as an expression, every arm must produce exactly the same type.matchpatterns support single values, multiple patterns with|, ranges with..=, guards withif, bindings with@, and tuple/struct/enum destructuring.if letfor a single pattern — more concise thanmatchwhen you only care about one case and want to ignore the rest.while letfor pattern-based loops — most idiomatic for processing a stack or iterator that yieldsOptionuntil exhausted.let-elsefor early validation — allows destructuring in aletline with a fallback that exits the function immediately, keeping the main flow flat without nesting.- Use
matchfor enums — exhaustiveness checking ensures you never forget to handle a new variant added to the enum.