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
matchexpression forces you to handle all possible cases, whileif letoffers 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.