↓ Skip to main content
  1. Rust/

Rust: Deep Dive into Pattern Matching - Destructuring, Bindings, and Guards

1876 words·9 mins· loading · loading · · ·Rust-middle

Introduction
#

Pattern matching in Rust is often mistaken for a mere analog of switch/case statements found in other languages. In practice, it is one of the most expressive and powerful tools in the language. Rust provides match, if let, and let expressions that destructure structs on the fly, evaluate complex conditions without nested if blocks, and eliminate invalid states right at compile time.

In previous articles, I covered enums in rust-enums and control flow constructs in rust-conditionals.

Today, let’s move beyond the basics and explore how Pattern Matching works in real-world software: from @-bindings in CLI utilities to slice patterns and state machines in production open-source projects.


1. Exhaustiveness and destructuring
#

The primary guarantee the compiler enforces on any match is exhaustiveness. If a type can take three states, you must explicitly handle all three, or supply a _ wildcard to catch everything else.

In the slider below, step 1 demonstrates how the compiler blocks an unhandled variant, while step 2 shows how the _ wildcard resolves the issue (though relying on wildcards is discouraged when adding new states):

Exhaustiveness and the _ wildcard
Step 1/2
 1// ?compile_fail
 2enum ConnectionState {
 3    Disconnected,
 4    Connecting,
 5    Connected { ip: String },
 6}
 7
 8fn handle_connection(state: ConnectionState) {
 9    match state {
10        ConnectionState::Disconnected => println!("Offline"),
11        ConnectionState::Connected { ip } => println!("Connected to {ip}"),
12        // Compile error: `Connecting` variant is not covered!
13    }
14}
15
16fn main() {
17    let state = ConnectionState::Connecting;
18    handle_connection(state);
19}
20

1. Compile Error: Non-exhaustive match

  • The Rust compiler strictly enforces exhaustiveness checks across all enum variants.
  • The Connecting variant is missing, so compilation fails with error E0004.

Destructuring structs and tuples on the fly
#

Pattern matching works not only in match, but also in ordinary let statements. You can extract nested fields in a single step:

struct Address {
    street: String,
    city: String,
}

struct Person {
    name: String,
    address: Address,
}

// Extract the name and nested address fields in one step:
let Person { 
    name, 
    address: Address { street, city } 
} = person;

Unneeded fields can be discarded using _ or the rest operator ..:

let Person { name, address: Address { city, .. } } = person;

Borrowing: Match Ergonomics and the legacy of ref
#

In modern Rust, matching on references feels natural thanks to Match Ergonomics (introduced in Rust 2018). When you need to match without consuming ownership (for non-Copy types), simply pass a reference into match:

let point = (String::from("sensor_1"), 42);

// Pass &point — the compiler automatically binds name as &String:
match &point {
    (name, 0) => println!("Sensor {name} at zero mark"),
    (name, val) if *val > 100 => println!("Threshold exceeded on sensor {name}: {val}"),
    _ => println!("Normal operation"),
}
// point remains accessible here because we matched on a reference
Note

Why is ref rarely seen in modern Rust code?

Prior to Match Ergonomics in Rust 2018, the compiler required explicitly declaring the binding mode in each pattern: (ref name, 0) or ref mut x.

Today, explicit ref is considered legacy syntax and is almost never needed in everyday code. Its only remaining use case is partial moves, where one field of a struct is moved by value while another is borrowed by reference:

struct Task {
    id: String,
    payload: Vec<u8>,
}

// payload is moved into ownership, while id is borrowed:
let Task { ref id, payload } = task;

Multiple patterns with |
#

When several arms share the exact same logic, combine them using the pipe | operator:

match status {
    "queued" | "pending" => println!("In processing queue"),
    "running" | "retrying" => println!("Running"),
    _ => println!("Completed or failed"),
}

2. @-bindings: A real-world example from dupe-krill
#

A common problem: check whether a number or object falls within a range, and immediately use that value in the arm body. Without @-bindings, you would either need nested if statements or duplicate range checks.

The @ operator solves this cleanly: it tests the subpattern and binds the matched value to a variable at the same time.

An excellent production example comes from the file duplicate finder dupe-krill (by Kornel Lesiński), which formats scan durations into human-readable strings using range matching:

// Source code from dupe-krill (src/ui.rs):
let nice_duration = match scan_duration.as_secs() {
    x @ 0..=5 => format!(
        "{:.1}s", 
        (x * 1_000_000_000 + u64::from(scan_duration.subsec_nanos())) as f64 / 1_000_000_000f64
    ),
    x @ 5..=59 => format!("{x}s"),
    x => format!("{}m{}s", x / 60, x % 60),
};

Notice how this works:

  • In x @ 0..=5, the compiler checks that seconds fall in 0..=5 and binds that number to x to format fractional seconds with nanoseconds (1.4s).
  • In x @ 5..=59, the number is printed as integer seconds (42s).
  • The final arm binds any remaining values to x and splits them into minutes and seconds (2m15s).

The code reads sequentially from top to bottom with zero boilerplate variables.


3. Slice Patterns and Guards: 3D file parser in normalize_obj
#

Match Guards add an arbitrary if condition to a match arm. The arm matches only if both the pattern and the guard condition evaluate to true.

Guards become especially expressive when paired with Slice Patterns.

Consider the OFF 3D model parser from normalize_obj (Julian Knodt). The parser splits lines by whitespace and matches on token slices:

// Parser excerpt from normalize_obj (src/off.rs):
match &tokens.as_slice() {
    // 1. Ignore comments: the first token starts with '#'
    [v, ..] if v.starts_with('#') => continue,

    // 2. Read format header:
    [off] if off.to_lowercase() == "off" => {
        curr = State::Counts;
    }

    // 3. Exact 3-element slice: vertex coordinates (x, y, z)
    [x, y, z] if curr == State::Verts => {
        let x = x.parse::<f64>()?;
        let y = y.parse::<f64>()?;
        let z = z.parse::<f64>()?;
        verts.push([x, y, z]);
    }

    // 4. Capture head and tail slice using `@ ..`:
    [cnt, r @ ..] if curr == State::Faces => {
        let _cnt = cnt.parse::<u32>()?;
        let v_idxs = r.iter().map(|vi| vi.parse::<usize>().unwrap()).collect();
        faces.push(v_idxs);
    }

    _ => panic!("Unexpected line in file"),
}

Key highlights:

  1. [v, ..] verifies that the line contains at least one word v, ignoring the rest ... The if v.starts_with('#') guard filters out comments.
  2. [x, y, z] strictly requires exactly three elements in the slice.
  3. [cnt, r @ ..] extracts the first element as face count cnt and binds the rest of the slice to r.

An entire parser state machine fits into a single, clean match without manual tokens.len() == 3 checks.


4. Interactive examples: Destructuring and guards
#

Explore and run the interactive examples with destructuring and guards below:

Destructuring, @-bindings, and Match Guards
Step 1/2
 1// Step 1: Deep struct destructuring and binding patterns (@)
 2struct Address {
 3    street: String,
 4    city: String,
 5}
 6
 7struct Person {
 8    name: String,
 9    address: Address,
10}
11
12fn main() {
13    let person = Person {
14        name: String::from("Alex"),
15        address: Address {
16            street: String::from("Broadway"),
17            city: String::from("New York"),
18        },
19    };
20
21    // 1. Extracting fields from nested structs in a single step:
22    let Person { name, address: Address { street, city } } = person;
23    println!("Name: {name}, City: {city}, Street: {street}");
24
25    // 2. Using @-binding to keep the value while checking a range:
26    let val = 15;
27    match val {
28        num @ 1..=10 => println!("Number {num} in range 1..=10"),
29        num @ 11..=20 => println!("Number {num} in range 11..=20"),
30        _ => println!("Other number"),
31    }
32}
33

1. Destructuring and @-bindings

  • Patterns allow dissecting nested structs and enums in a single expression without intermediate variables.
  • The @ operator binds the matched value to a variable while simultaneously checking a range or pattern.

5. if let chains (let_chains) and while let
#

When a full match is excessive (for instance, when only checking a single positive case), if let comes in handy.

Modern Rust allows chaining multiple patterns with boolean conditions using && (let chains):

let result: Option<Result<i32, &str>> = Some(Ok(42));

// Unpack nested Option and Result types in a single condition:
if let Some(res) = result && let Ok(val) = res && val > 10 {
    println!("Extracted valid value: {val}");
}

For consuming queues, iterators, or Tokio channels, the while let loop is ideal:

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

// Loops as long as pop() returns Some(item):
while let Some(top) = stack.pop() {
    println!("Popped from stack: {top}");
}

Try the interactive example with chained conditions:

if let chains and while let loop
Step 1/2
 1// Step 1: Combining conditions with if-let chains (let_chains)
 2fn main() {
 3    let result: Option<Result<i32, &str>> = Some(Ok(42));
 4
 5    // if-let chain combining pattern matching and boolean expressions with &&:
 6    if let Some(res) = result && let Ok(val) = res && val > 10 {
 7        println!("Successfully extracted value greater than 10: {val}");
 8    }
 9}
10

1. if-let chains (let_chains)

  • In modern Rust, multiple let pattern bindings and boolean conditions can be chained using &&.
  • This eliminates deeply nested if let blocks and keeps logic flat and readable.

Test your knowledge
#

Test your understanding of advanced Pattern Matching in Rust:

Article read
Please rate how helpful and clear this article was to you
▶ Article series
About Rust - This article is part of a series.

Related