Pattern Matching in Rust with match and if let

I break down Rust's match expression — exhaustiveness, destructuring, guards, and if let — and compare each to the TypeScript patterns they replace.

Pattern Matching in Rust with match and if let

Add a new variant to a TypeScript union type and nothing breaks. The existing switch statements keep running. Your code silently handles the new variant as the default case — which might not exist — or just falls through without doing anything useful. The TypeScript compiler won’t stop you.

Do the same thing in Rust — add a variant to an enum — and every match on that enum fails to compile. Every single one, immediately, with an error pointing to exactly where the new case isn’t handled.

That difference isn’t a quirk of the syntax. It’s a deliberate design: Rust’s match is a structural contract with the compiler, not a sugared switch statement. Once that clicked for me, I stopped writing match expressions reluctantly and started reaching for them first.

Series: Part 6 of 12. Start with Part 1: Learning Rust as a TypeScript Developer · Previous: Ownership and Borrowing in Rust Explained Simply

On this page

Exhaustiveness: the compiler knows every case

Here’s a TypeScript switch on an HTTP status category:

type Status = "success" | "not_found" | "server_error";

function describe(status: Status): string {
  switch (status) {
    case "success":    return "All good";
    case "not_found":  return "Missing";
    default:           return "Something went wrong";
  }
}

Now you add "unauthorized" to the Status union. The function still compiles. "unauthorized" hits the default case and returns "Something went wrong", which is probably the wrong answer. You won’t find out until runtime — if you find out at all.

The Rust equivalent refuses to let that happen:

enum Status {
    Success,
    NotFound,
    ServerError,
}

fn describe(status: Status) -> &'static str {
    match status {
        Status::Success     => "All good",
        Status::NotFound    => "Missing",
        Status::ServerError => "Something went wrong",
    }
}

Add Unauthorized to the enum without updating the match and cargo build outputs:

error[E0004]: non-exhaustive patterns: `Status::Unauthorized` not covered

The error names the exact variant you missed and the file it’s in. No runtime surprise, no silent wrong path — a compile-time rejection at the point where you’re still thinking about the change.

The _ wildcard acts as an explicit catch-all when you genuinely want one:

match status {
    Status::Success => "All good",
    _               => "Something went wrong",
}

The difference from a TypeScript default: is that using _ in Rust is a deliberate choice. If you add a new variant and already have a _ arm, the match still compiles — you’ve said you want everything else handled together. That’s fine when it’s intentional. The compiler just stops individually accounting for each case, so it’s worth reaching for _ consciously rather than as a habit.

The _ pattern also never binds the value. If you want the value for inspection but don’t need a specific name, you can use other or any valid identifier instead — the key distinction is that _ truly discards it.

Destructuring in match arms

TypeScript’s switch is limited to comparing values directly. Rust’s match can reach inside a value and bind its contents to variables in the same expression.

Destructuring enum variants with data is where this becomes immediately useful. The standard Option<T> type has two variants: Some(T) when a value exists and None when it doesn’t. Rather than checking for null and then accessing the value separately, you destructure in the match arm itself:

let name: Option<&str> = Some("Soumitra");

match name {
    Some(n) => println!("Name: {n}"),
    None    => println!("No name provided"),
}

The variable n is only bound when the variant is Some. In the None arm there’s nothing to bind — no data lives there. This replaces the TypeScript pattern of if (name !== null) { use(name) }, but the Rust version makes the binding explicit and scopes it to the arm.

Destructuring structs works the same way. A pattern can match specific field values as literals and bind others as variables in the same expression:

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

let p = Point { x: 0, y: 7 };

match p {
    Point { x: 0, y } => println!("On the y axis at {y}"),
    Point { x, y: 0 } => println!("On the x axis at {x}"),
    Point { x, y }    => println!("Neither axis: ({x}, {y})"),
}

The first arm reads: “if x equals the literal 0, bind y to a variable named y and run this code.” The arms describe the structure of the value, not just its identity.

Destructuring tuples is equally clean:

let coords = (0, -3);

match coords {
    (0, 0) => println!("Origin"),
    (x, 0) => println!("On the x axis at {x}"),
    (0, y) => println!("On the y axis at {y}"),
    (x, y) => println!("Point: ({x}, {y})"),
}

When you have a struct with several fields but only care about one, the .. operator ignores the rest:

struct Config {
    host: String,
    port: u16,
    debug: bool,
}

match config {
    Config { debug: true, .. } => println!("debug mode on"),
    Config { .. }              => println!("debug mode off"),
}

This keeps the pattern focused on exactly what the code needs to check.

Check this before moving on

  • You can explain why adding a new variant to a Rust enum causes existing match expressions to fail compilation
  • You understand that _ matches anything without binding the value, while a named variable like n binds it
  • You know how to combine a literal check and a variable binding in the same struct pattern

Match guards: adding conditions to patterns

Patterns describe the shape of data. Sometimes the shape isn’t enough — you also need to check a specific value inside it.

A match guard is an if condition placed after a pattern in a match arm. The arm only executes if both the pattern matches and the condition is true:

let num: Option<i32> = Some(4);

match num {
    Some(x) if x % 2 == 0 => println!("{x} is even"),
    Some(x)               => println!("{x} is odd"),
    None                  => println!("nothing here"),
}

There’s no way to express “Some and even” as a pure pattern. The shape (Some) and the condition (x % 2 == 0) each handle their own part of the check.

You can also combine multiple patterns in one arm with | and apply a guard across all of them:

let x = 4;
let active = true;

match x {
    1 | 2 | 3 if active => println!("small and active"),
    _                   => println!("other"),
}

One subtlety: the guard applies to the entire 1 | 2 | 3 group, not just the last value. Think of it as (1 | 2 | 3) if active, not 1 | 2 | (3 if active).

The @ binding operator is useful when you want to test a value against a range and also bind it to a variable in the same arm:

match id {
    n @ 1..=100 => println!("valid ID: {n}"),
    _           => println!("out of range"),
}

Without @, a range arm like 1..=100 => ... matches successfully but leaves the actual value inaccessible inside the arm. With n @, you get both the test and the binding.

if let and while let: matching a single case

A three-arm match where only one arm does real work is verbose. if let handles the case where you only care about one pattern:

// TypeScript: check then access separately
if (user !== null && user !== undefined) {
  console.log(user.name);
}
// Rust: match and bind in one expression
if let Some(user) = get_user() {
    println!("{}", user.name);
}

if let does exactly what a two-arm match does — pattern-matches the value, binds variables from it, runs the block — without forcing you to write an explicit arm for every other case. The tradeoff is that you lose exhaustiveness checking. The compiler won’t remind you when a new variant is added.

You can chain if let with else if, else if let, and a plain else:

if let Some(color) = favorite_color {
    println!("using {color}");
} else if let Ok(age) = age_str.parse::<u8>() {
    println!("age: {age}");
} else {
    println!("using default");
}

while let runs a loop as long as a pattern keeps matching — handy for processing items from a stack or a channel:

let mut stack = vec![1, 2, 3];

while let Some(top) = stack.pop() {
    println!("{top}");
}

The loop stops cleanly when pop() returns None. This is more readable than a loop with a manual break condition, and top is only in scope for iterations where a value actually exists.

Nested patterns

Patterns compose. You can match an enum variant that itself contains another enum, reaching through two levels of structure in a single arm:

enum Color {
    Rgb(u8, u8, u8),
    Hsv(u8, u8, u8),
}

enum Message {
    ChangeColor(Color),
    Move { x: i32, y: i32 },
    Write(String),
}

match msg {
    Message::ChangeColor(Color::Rgb(r, g, b)) => {
        println!("RGB: {r}, {g}, {b}");
    }
    Message::ChangeColor(Color::Hsv(h, s, v)) => {
        println!("HSV: {h}, {s}, {v}");
    }
    Message::Move { x, y } => println!("Move to ({x}, {y})"),
    Message::Write(text)   => println!("Text: {text}"),
}

The first arm reads from the outside in: “if the message is a ChangeColor containing an Rgb colour, bind its three components to r, g, and b.” In TypeScript you’d need nested if checks or type guards at each level; here the structure of the pattern mirrors the structure of the data.

This scales to Result<Option<T>, E> and similar nested types you’ll encounter regularly in Rust APIs:

match fetch_user(id) {
    Ok(Some(user))  => println!("found: {}", user.name),
    Ok(None)        => println!("not found"),
    Err(e)          => println!("error: {e}"),
}

All three cases are distinct shapes, and Rust requires all three to be handled.

Where TypeScript intuitions break

Pattern shadowing. In TypeScript, a switch case compares against values from an outer scope naturally. In Rust, naming a variable inside a match pattern creates a new variable that shadows any outer variable with the same name — even if the name happens to match:

let y = 10;
let x = Some(5);

match x {
    Some(y) => println!("matched: {y}"), // this y is 5, NOT the outer 10
    _       => println!("no match"),
}

If you meant to compare against the outer y, a match guard is the correct approach:

match x {
    Some(n) if n == y => println!("equals outer y"),
    _                 => println!("does not match"),
}

This trips up almost every TypeScript developer on their first time. The pattern name looks like a comparison, but it isn’t — it’s a binding.

_ versus _name. A lone _ doesn’t bind the value at all. A name that starts with _ (like _unused) still binds the value, which affects ownership. If you write if let Some(_s) = owned_value, the owned value is moved into _s. If you write if let Some(_) = owned_value, nothing moves. For Copy types this makes no difference, but for heap-owning types like String, the distinction is real.

if let and the exhaustiveness gap. Every match is exhaustive. Every if let is not. It’s easy to use if let out of habit and then add a new variant without realising you’ve silently skipped a case. Reach for match when you need the compiler to enforce completeness, and if let when you genuinely only care about one outcome.

match is not a switch

The shift for me was stopping to think of match as “a switch that can destructure” and starting to think of it as “a way to make the compiler understand the shape of my data.”

Each match arm is a claim about what form a value can take. The compiler verifies that your claims cover every possibility. When you add a new variant to an enum, the gaps surface immediately — not at runtime, not in a test, but while you’re still thinking about the change.

if let and while let trade that exhaustiveness for clarity when you genuinely only need one arm. The right choice depends on whether the compiler watching for missed cases is useful to you.

Either way, the underlying idea is the same: describe the structure of your data, and the compiler confirms your code accounts for it correctly. Once you’re used to that guarantee, the silence of a TypeScript default: starts to feel like missing information rather than convenience.

Sources