Pattern Matching: Destructuring and Guards

Pattern matching is one of those features that, once you've used it properly, makes you wonder how you ever lived without it. At its core, pattern matching is a control flow mechanism that...

Key Insights

  • Pattern matching combines conditional logic with data extraction in a single expression, eliminating the nested if/else chains that plague traditional imperative code
  • Guards extend pattern matching with arbitrary boolean conditions, letting you express complex matching logic that pure structural patterns cannot capture
  • Exhaustiveness checking transforms pattern matching from a convenience feature into a compile-time safety net that catches missing cases before runtime

Introduction to Pattern Matching

Pattern matching is one of those features that, once you’ve used it properly, makes you wonder how you ever lived without it. At its core, pattern matching is a control flow mechanism that simultaneously tests the shape of data and extracts values from it. Unlike traditional if/else chains that separate these concerns, pattern matching unifies them into declarative expressions.

Consider how you might handle a result type in traditional imperative style versus pattern matching:

// Traditional approach: check then extract
fn process_result_imperative(result: Result<i32, String>) -> String {
    if result.is_ok() {
        let value = result.unwrap();
        if value > 100 {
            format!("Large value: {}", value)
        } else {
            format!("Small value: {}", value)
        }
    } else {
        let error = result.unwrap_err();
        format!("Error: {}", error)
    }
}

// Pattern matching: test and extract in one expression
fn process_result_pattern(result: Result<i32, String>) -> String {
    match result {
        Ok(value) if value > 100 => format!("Large value: {}", value),
        Ok(value) => format!("Small value: {}", value),
        Err(error) => format!("Error: {}", error),
    }
}

The pattern matching version is not just shorter—it’s structurally clearer. Each arm declares what shape of data it handles and what to do with it. There’s no possibility of calling unwrap on the wrong variant because extraction only happens when the pattern matches.

Basic Destructuring Patterns

Destructuring is the extraction half of pattern matching. When you write a pattern, you’re describing the structure you expect, and the language binds matching components to variables.

Tuples are the simplest case:

let point = (10, 20, 30);

// Destructure all elements
let (x, y, z) = point;

// Ignore elements with underscore
let (x, _, z) = point;

// Ignore trailing elements with ..
let (x, ..) = point;

Structs work similarly, though the syntax varies by language:

struct User {
    name: String,
    age: u32,
    active: bool,
}

fn describe_user(user: User) -> String {
    match user {
        User { name, age, active: true } => {
            format!("{} (age {}) is active", name, age)
        }
        User { name, active: false, .. } => {
            format!("{} is inactive", name)
        }
    }
}

Nested destructuring handles complex data without intermediate variables:

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

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

fn get_location(person: Person) -> String {
    let Person { 
        name, 
        address: Address { city, country } 
    } = person;
    
    format!("{} lives in {}, {}", name, city, country)
}

This nested extraction would require multiple lines of field access in languages without pattern matching. Here, the structure of the pattern mirrors the structure of the data.

Enum and Variant Matching

Pattern matching truly shines with enums (sum types, tagged unions, algebraic data types—different names for the same concept). Each variant can carry different data, and pattern matching lets you handle each case distinctly.

enum Message {
    Text(String),
    Image { url: String, width: u32, height: u32 },
    Reaction(char),
    Deleted,
}

fn render_message(msg: Message) -> String {
    match msg {
        Message::Text(content) => content,
        Message::Image { url, width, height } => {
            format!("<img src='{}' {}x{}>", url, width, height)
        }
        Message::Reaction(emoji) => format!("[{}]", emoji),
        Message::Deleted => String::from("[deleted]"),
    }
}

The critical feature here is exhaustiveness checking. If you add a new variant to Message, the compiler will flag every match expression that doesn’t handle it. This is not a linting suggestion—it’s a hard error that prevents you from shipping code with unhandled cases.

Kotlin’s sealed classes provide similar guarantees:

sealed class NetworkResult<out T> {
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Error(val code: Int, val message: String) : NetworkResult<Nothing>()
    object Loading : NetworkResult<Nothing>()
}

fun <T> handleResult(result: NetworkResult<T>): String = when (result) {
    is NetworkResult.Success -> "Got: ${result.data}"
    is NetworkResult.Error -> "Error ${result.code}: ${result.message}"
    NetworkResult.Loading -> "Loading..."
}

Guards: Adding Conditional Logic

Pure structural patterns can only match on shape. Guards add arbitrary boolean conditions that must also be satisfied for the arm to match.

fn categorize_number(n: i32) -> &'static str {
    match n {
        x if x < 0 => "negative",
        0 => "zero",
        x if x % 2 == 0 => "positive even",
        _ => "positive odd",
    }
}

Guards are evaluated after the pattern matches structurally. This ordering matters when patterns overlap:

fn process_score(score: Option<u32>) -> String {
    match score {
        Some(s) if s >= 90 => String::from("Excellent"),
        Some(s) if s >= 70 => String::from("Good"),
        Some(s) if s >= 50 => String::from("Pass"),
        Some(s) => format!("Fail: {}", s),
        None => String::from("No score"),
    }
}

Each arm matches Some, but the guards create distinct ranges. The order matters—guards are checked top to bottom, and the first matching arm wins.

You can combine multiple conditions in guards:

fn validate_user(name: &str, age: u32, verified: bool) -> &'static str {
    match (name, age, verified) {
        (n, _, _) if n.is_empty() => "Name required",
        (_, a, _) if a < 18 => "Must be 18+",
        (_, _, false) => "Verification required",
        _ => "Valid",
    }
}

Advanced Patterns: Or-Patterns and Bindings

Or-patterns let you handle multiple variants with shared logic:

enum Key {
    Up,
    Down,
    Left,
    Right,
    Space,
    Enter,
    Escape,
}

fn handle_key(key: Key) -> &'static str {
    match key {
        Key::Up | Key::Down => "vertical movement",
        Key::Left | Key::Right => "horizontal movement",
        Key::Space | Key::Enter => "confirm action",
        Key::Escape => "cancel",
    }
}

The @ binding captures a value while also matching against a pattern:

fn describe_age(age: u32) -> String {
    match age {
        0 => String::from("newborn"),
        a @ 1..=12 => format!("child aged {}", a),
        a @ 13..=19 => format!("teenager aged {}", a),
        a @ 20..=64 => format!("adult aged {}", a),
        a => format!("senior aged {}", a),
    }
}

This is particularly useful when you need both the matched value and want to constrain it to a pattern.

Real-World Applications

Pattern matching excels at recursive data structures. Here’s a simple expression evaluator:

enum Expr {
    Literal(i64),
    Add(Box<Expr>, Box<Expr>),
    Mul(Box<Expr>, Box<Expr>),
    Neg(Box<Expr>),
    If {
        condition: Box<Expr>,
        then_branch: Box<Expr>,
        else_branch: Box<Expr>,
    },
}

fn evaluate(expr: Expr) -> i64 {
    match expr {
        Expr::Literal(n) => n,
        Expr::Add(left, right) => evaluate(*left) + evaluate(*right),
        Expr::Mul(left, right) => evaluate(*left) * evaluate(*right),
        Expr::Neg(inner) => -evaluate(*inner),
        Expr::If { condition, then_branch, else_branch } => {
            if evaluate(*condition) != 0 {
                evaluate(*then_branch)
            } else {
                evaluate(*else_branch)
            }
        }
    }
}

State machines become explicit:

enum ConnectionState {
    Disconnected,
    Connecting { attempt: u32 },
    Connected { session_id: String },
    Reconnecting { attempt: u32, last_session: String },
}

enum Event {
    Connect,
    Success(String),
    Failure,
    Disconnect,
}

fn transition(state: ConnectionState, event: Event) -> ConnectionState {
    match (state, event) {
        (ConnectionState::Disconnected, Event::Connect) => {
            ConnectionState::Connecting { attempt: 1 }
        }
        (ConnectionState::Connecting { .. }, Event::Success(id)) => {
            ConnectionState::Connected { session_id: id }
        }
        (ConnectionState::Connecting { attempt }, Event::Failure) if attempt < 3 => {
            ConnectionState::Connecting { attempt: attempt + 1 }
        }
        (ConnectionState::Connecting { .. }, Event::Failure) => {
            ConnectionState::Disconnected
        }
        (ConnectionState::Connected { .. }, Event::Disconnect) => {
            ConnectionState::Disconnected
        }
        (state, _) => state, // Ignore invalid transitions
    }
}

Performance and Best Practices

Order patterns from most specific to least specific. Compilers can optimize pattern matching into jump tables or decision trees, but putting common cases first can improve branch prediction.

Avoid redundant guards when patterns suffice:

// Suboptimal: guard duplicates pattern capability
match value {
    x if x == 0 => "zero",
    _ => "nonzero",
}

// Better: use literal pattern
match value {
    0 => "zero",
    _ => "nonzero",
}

Use exhaustiveness as documentation. When you add a wildcard arm, you’re telling the compiler “I’ve considered all cases.” Be deliberate about this—sometimes it’s better to list all variants explicitly so new ones trigger compile errors:

// Explicit: adding a variant forces review
match status {
    Status::Pending => handle_pending(),
    Status::Active => handle_active(),
    Status::Completed => handle_completed(),
}

// Implicit: new variants silently fall through
match status {
    Status::Pending => handle_pending(),
    _ => handle_other(),
}

Pattern matching transforms how you think about conditional logic. Instead of asking “what conditions are true?”, you ask “what shape does this data have?” That shift in perspective leads to code that’s simultaneously more concise, more correct, and more maintainable.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.