Rust Pattern Matching: match and if let

Pattern matching is one of Rust's most powerful features, fundamentally different from the switch statements you've used in C, Java, or JavaScript. While a switch statement simply compares values,...

Key Insights

  • Pattern matching in Rust provides exhaustiveness checking at compile time, eliminating entire classes of bugs that plague traditional switch statements and if/else chains
  • The match expression forces you to handle all possible cases, while if let offers syntactic sugar when you only care about one specific pattern
  • Advanced patterns like match guards, @ bindings, and destructuring enable you to write declarative code that’s both safer and more expressive than imperative alternatives

Introduction to Pattern Matching in Rust

Pattern matching is one of Rust’s most powerful features, fundamentally different from the switch statements you’ve used in C, Java, or JavaScript. While a switch statement simply compares values, Rust’s pattern matching destructures data, tests conditions, and binds variables—all in a single, compiler-verified expression.

Consider this common scenario: checking if an optional value exists. In many languages, you’d write:

// The verbose way (don't do this)
fn get_user_age(user: Option<User>) -> String {
    if user.is_some() {
        let u = user.unwrap();
        format!("Age: {}", u.age)
    } else {
        "No user found".to_string()
    }
}

With pattern matching, this becomes:

fn get_user_age(user: Option<User>) -> String {
    match user {
        Some(u) => format!("Age: {}", u.age),
        None => "No user found".to_string(),
    }
}

The difference isn’t just syntactic. The match version is exhaustive—the compiler guarantees you’ve handled every possible case. Forget to handle None? Compilation fails. This is pattern matching’s killer feature.

The match Expression

Every match expression must be exhaustive. You cannot compile code that leaves cases unhandled. This is enforced at compile time, making entire categories of runtime errors impossible.

enum HttpStatus {
    Ok,
    NotFound,
    ServerError,
    Unauthorized,
}

fn handle_response(status: HttpStatus) -> &'static str {
    match status {
        HttpStatus::Ok => "Success",
        HttpStatus::NotFound => "Resource not found",
        HttpStatus::ServerError => "Internal error",
        HttpStatus::Unauthorized => "Access denied",
    }
}

If you add a new variant to HttpStatus later, every match expression becomes a compile error until you handle it. This is refactoring safety that dynamic languages can’t provide.

Matching Option and Result

The standard library types Option<T> and Result<T, E> are designed around pattern matching:

fn divide(numerator: f64, denominator: f64) -> Option<f64> {
    if denominator == 0.0 {
        None
    } else {
        Some(numerator / denominator)
    }
}

fn describe_division(result: Option<f64>) -> String {
    match result {
        Some(value) if value > 100.0 => format!("Large result: {}", value),
        Some(value) if value < 0.0 => format!("Negative result: {}", value),
        Some(value) => format!("Result: {}", value),
        None => "Cannot divide by zero".to_string(),
    }
}

Notice the match guards—the if conditions after patterns. These let you add extra logic without nesting. The patterns are evaluated top-to-bottom, so order matters.

Destructuring Complex Data

Pattern matching shines when working with structured data:

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

fn describe_point(point: Point) -> String {
    match point {
        Point { x: 0, y: 0 } => "Origin".to_string(),
        Point { x: 0, y } => format!("On Y-axis at {}", y),
        Point { x, y: 0 } => format!("On X-axis at {}", x),
        Point { x, y } if x == y => format!("Diagonal at {}", x),
        Point { x, y } => format!("Point at ({}, {})", x, y),
    }
}

You can destructure tuples, arrays, and nested structures:

fn process_tuple(data: (i32, Option<String>, bool)) -> String {
    match data {
        (0, None, _) => "Zero with no string".to_string(),
        (n, Some(s), true) if n > 0 => format!("Positive {}: {}", n, s),
        (n, Some(s), false) => format!("Disabled {}: {}", n, s),
        (n, None, _) => format!("Just number: {}", n),
    }
}

Ranges and Multiple Patterns

Match arms can handle ranges and multiple patterns with |:

fn classify_age(age: u32) -> &'static str {
    match age {
        0..=12 => "Child",
        13..=19 => "Teenager",
        20..=64 => "Adult",
        65.. => "Senior",
    }
}

fn is_weekend(day: &str) -> bool {
    match day {
        "Saturday" | "Sunday" => true,
        _ => false,
    }
}

The _ wildcard matches anything, acting as a catch-all. Without it, the compiler would complain about non-exhaustive patterns.

The if let Syntax

When you only care about one pattern, match feels verbose. Enter if let:

// With match
fn print_name(name: Option<String>) {
    match name {
        Some(n) => println!("Name: {}", n),
        None => {}
    }
}

// With if let
fn print_name(name: Option<String>) {
    if let Some(n) = name {
        println!("Name: {}", n);
    }
}

The if let syntax is purely ergonomic—it compiles to the same code as match. Use it when you’re only interested in one case and don’t need exhaustiveness checking.

Chaining with else if let

You can chain multiple patterns:

enum Message {
    Text(String),
    Image(String, u32, u32),
    Video(String, u32),
}

fn process_message(msg: Message) {
    if let Message::Text(content) = msg {
        println!("Text: {}", content);
    } else if let Message::Image(url, width, height) = msg {
        println!("Image {}x{}: {}", width, height, url);
    } else if let Message::Video(url, duration) = msg {
        println!("Video ({}s): {}", duration, url);
    }
}

However, once you have more than two branches, match is usually clearer and gives you exhaustiveness checking.

When to Use if let vs match

Use if let when:

  • You only care about one pattern
  • The else case is trivial or nonexistent
  • You want to avoid rightward drift in nested code

Use match when:

  • You need to handle multiple cases
  • Exhaustiveness checking is valuable
  • You want the compiler to catch missing cases

Advanced Pattern Matching Techniques

The @ Binding Operator

The @ operator lets you bind a variable while also testing it:

enum Role {
    Admin { level: u8 },
    User { id: u32 },
    Guest,
}

fn check_permission(role: Role) -> bool {
    match role {
        Role::Admin { level: l @ 3..=5 } => {
            println!("High-level admin: {}", l);
            true
        }
        Role::Admin { level: l } => {
            println!("Low-level admin: {}", l);
            l >= 1
        }
        Role::User { id } if id < 1000 => true,
        _ => false,
    }
}

The @ binding captures the value while the pattern tests it, avoiding a second variable extraction.

Ignoring Values with .. and _

Use .. to ignore multiple fields in structs or tuple elements:

struct Config {
    host: String,
    port: u16,
    timeout: u64,
    retries: u8,
    debug: bool,
}

fn needs_retry(config: Config) -> bool {
    match config {
        Config { retries: r, .. } if r > 0 => true,
        _ => false,
    }
}

This is particularly useful with large structs where you only care about a few fields.

Practical Use Cases

Error Handling with Result

Pattern matching makes error handling explicit and type-safe:

use std::fs::File;
use std::io::Read;

fn read_config(path: &str) -> Result<String, String> {
    let mut file = match File::open(path) {
        Ok(f) => f,
        Err(e) => return Err(format!("Failed to open: {}", e)),
    };
    
    let mut contents = String::new();
    match file.read_to_string(&mut contents) {
        Ok(_) => Ok(contents),
        Err(e) => Err(format!("Failed to read: {}", e)),
    }
}

State Machine Implementation

Pattern matching is ideal for state machines:

enum ConnectionState {
    Disconnected,
    Connecting,
    Connected { session_id: String },
    Error { message: String },
}

impl ConnectionState {
    fn transition(self, event: Event) -> Self {
        match (self, event) {
            (ConnectionState::Disconnected, Event::Connect) => {
                ConnectionState::Connecting
            }
            (ConnectionState::Connecting, Event::Success(id)) => {
                ConnectionState::Connected { session_id: id }
            }
            (ConnectionState::Connecting, Event::Failure(msg)) => {
                ConnectionState::Error { message: msg }
            }
            (ConnectionState::Connected { .. }, Event::Disconnect) => {
                ConnectionState::Disconnected
            }
            (state, _) => state, // Invalid transitions keep current state
        }
    }
}

Parsing and Validation

Pattern matching excels at parsing structured data:

fn parse_command(input: &str) -> Result<Command, String> {
    let parts: Vec<&str> = input.split_whitespace().collect();
    
    match parts.as_slice() {
        ["quit"] | ["exit"] => Ok(Command::Quit),
        ["help"] => Ok(Command::Help),
        ["set", key, value] => Ok(Command::Set {
            key: key.to_string(),
            value: value.to_string(),
        }),
        ["get", key] => Ok(Command::Get(key.to_string())),
        _ => Err(format!("Unknown command: {}", input)),
    }
}

Common Pitfalls and Best Practices

Unreachable Patterns

The compiler warns about unreachable patterns:

fn check_value(x: i32) -> &'static str {
    match x {
        _ => "anything",
        0 => "zero", // Warning: unreachable pattern
    }
}

Always put specific patterns before general ones.

Refactoring if/else Chains

Nested if/else chains are often clearer as match expressions:

// Before: hard to read
fn process(status: Status, role: Role) -> Action {
    if status == Status::Active {
        if role == Role::Admin {
            Action::FullAccess
        } else if role == Role::User {
            Action::LimitedAccess
        } else {
            Action::Deny
        }
    } else {
        Action::Deny
    }
}

// After: clear and exhaustive
fn process(status: Status, role: Role) -> Action {
    match (status, role) {
        (Status::Active, Role::Admin) => Action::FullAccess,
        (Status::Active, Role::User) => Action::LimitedAccess,
        _ => Action::Deny,
    }
}

Pattern matching isn’t just about syntax—it’s about making illegal states unrepresentable and impossible cases uncompilable. Master it, and you’ll write more reliable Rust code with less effort.

Liked this? There's more.

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