I Stopped Writing match Everywhere in Rust. Here Is What I Write Instead.
The first few months of writing Rust, I reached for match on everything. An Option? Match on it. A Result? Match on it. A boolean check on…
I Stopped Writing match Everywhere in Rust. Here Is What I Write Instead.

The first few months of writing Rust, I reached for match on everything. An Option? Match on it. A Result? Match on it. A boolean check on an enum variant? Match on it. Match is expressive and exhaustive and the compiler will not let you forget a case, so it felt like the responsible choice. What I did not notice for a while was how much noise I was adding to code that had a straightforward intent, and how much of that noise was just boilerplate the language already had a cleaner answer for.
Rust has three constructs that handle specific, common patterns more cleanly than a full match block: if let, while let, and matches!. They are not shortcuts or clever tricks. They express intent more precisely than a full match block, and reading the code later tells you something the match version does not.
if let: when you only care about one variant
The pattern that if let replaces looks like this:
fn get_config_value() -> Option<String> {
Some(String::from("production"))
}
fn main() {
let config = get_config_value();
match config {
Some(value) => println!("Config: {}", value),
None => {} // nothing to do
}
}
The None => {} arm is not doing anything. It exists because match requires exhaustiveness. The code is saying "I care about the Some case" but it is forced to also say "I do not care about the None case" in a way that takes up space and draws the reader's eye to something irrelevant.
if let says the same thing without the noise:
fn main() {
let config = get_config_value();
if let Some(value) = config {
println!("Config: {}", value);
}
}
Read it aloud: “If config is Some, bind the inner value to value.” That is exactly what the code does. There is no empty arm, no None => {}. The reader does not have to process a case that the author confirmed is irrelevant.
if let also works cleanly with an else branch when you need one:
fn main() {
let config = get_config_value();
if let Some(value) = config {
println!("Using config: {}", value);
} else {
println!("No config found, using defaults");
}
}
The point where if let stops being the right tool is when multiple variants require meaningful work. At that point, the exhaustiveness checking of match becomes valuable instead of verbose. Once you have three or more arms that all do real work, match is earning its keep.
while let: consuming a sequence with a specific exit condition
while let is if let applied to a loop condition. The pattern it replaces comes up whenever you are draining a stack, processing messages from a channel, or consuming any producer that signals completion through None:
fn main() {
let mut stack = vec![1, 2, 3, 4, 5];
loop {
match stack.pop() {
Some(value) => println!("{}", value),
None => break,
}
}
}
The loop with a match inside is a common enough shape that reading it takes a moment to parse. You have to recognize the None => break pattern and understand that it is the exit condition. while let makes that exit condition part of the loop head itself, where exit conditions belong:
fn main() {
let mut stack = vec![1, 2, 3, 4, 5];
while let Some(value) = stack.pop() {
println!("{}", value);
}
}
The loop runs as long as pop() returns Some. When it returns None, the loop exits. This is structurally the same as a while loop on a boolean condition, just for a pattern rather than a predicate. The success condition is in the loop header instead of buried inside the loop body.
A more realistic example: processing messages from a channel until the sender disconnects.
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel::<String>();
std::thread::spawn(move || {
tx.send(String::from("first")).unwrap();
tx.send(String::from("second")).unwrap();
// sender drops here, channel closes
});
while let Ok(message) = rx.recv() {
println!("Received: {}", message);
}
}
rx.recv() returns Ok(message) when a message arrives and Err when the channel closes. This is the idiomatic pattern for consuming a channel until the sender disconnects, instead of manually calling recv() inside a loop.
matches!: when you only need a bool
Sometimes you do not need the value inside a variant. You need to know whether a value matches a particular pattern, and the result is a boolean you are using in a condition, a filter, or an assertion. Conceptually, matches! answers the same kind of question as ==: "does this value match what I am looking for?" But it does so using Rust's pattern syntax instead of equality comparison.
The match version:
#[derive(Debug)]
enum Status {
Active,
Inactive,
Pending(String),
}
fn main() {
let status = Status::Pending(String::from("review"));
let is_pending = match status {
Status::Pending(_) => true,
_ => false,
};
println!("{}", is_pending);
}
That match block with a _ => false arm is a tell. Any time you write match with one arm returning true and a wildcard returning false, matches! replaces it cleanly:
fn main() {
let status = Status::Pending(String::from("review"));
let is_pending = matches!(status, Status::Pending(_));
println!("{}", is_pending);
}
matches! also accepts guard conditions, which lets you match on a variant and a constraint simultaneously:
fn main() {
let statuses = vec![
Status::Active,
Status::Pending(String::from("legal")),
Status::Pending(String::from("review")),
Status::Inactive,
];
let review_items: Vec<_> = statuses
.iter()
.filter(|s| matches!(s, Status::Pending(reason) if reason == "review"))
.collect();
println!("{} items pending review", review_items.len());
}
The guard if reason == "review" inside matches! narrows the pattern beyond just variant membership. This is the version that would otherwise require either a method on Status or a verbose closure with a full match block.
The actual rule
A simple rule of thumb:
- Use
if letwhen you care about one variant. - Use
while letwhen a loop should continue while a pattern matches. - Use
matches!when all you need is a boolean. - Use
matchwhen every variant matters.
match remains the right tool when you care about multiple variants, when the exhaustiveness check is doing real work, or when each arm is complex enough that the structured layout helps readability. The goal is not replacing every match. The goal is writing code where the construct you choose signals the intent.
The next natural step from here is
let else, which deserves its own treatment. If closures and iterators are the features you reach for after pattern matching, that article is here.
$7 — Zero to Rust: A Systems Programmer’s Field Guide You can read the free sample here — Free 20-page sample
메타데이터
- post_id
- b8db2e6f15b7
- slug
- i-stopped-writing-match-everywhere-in-rust-here-is-what-i-write-instead-b8db2e6f15b7
- url
- https://medium.com/zero-to-rust-go-from-beginner-to-rust-expert/i-stopped-writing-match-everywhere-in-rust-here-is-what-i-write-instead-b8db2e6f15b7
- canonical_url
- https://medium.com/zero-to-rust-go-from-beginner-to-rust-expert/i-stopped-writing-match-everywhere-in-rust-here-is-what-i-write-instead-b8db2e6f15b7
- author_url
- https://medium.com/@zeeshankhan0094
- status
- ok
- fetched_at
- 2026-07-15 01:23:23