I was reviewing a PR last year where someone had written twelve match arms for an enum — four groups of three variants that all did the same thing. Each group was identical code, copy-pasted three times. I left a one-line comment: “or patterns.” The whole match collapsed from 36 lines to 12.
Most Rust developers learn the basics of match early and never dig into the full pattern syntax. That’s a shame, because there are three features that eliminate a ton of redundancy: or patterns, @ bindings, and rest patterns.
Or Patterns: Matching Multiple Cases
The | operator lets you combine multiple patterns into a single arm. If any of the patterns match, the arm fires:
enum Day {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday,
}
fn is_weekend(day: &Day) -> bool {
match day {
Day::Saturday | Day::Sunday => true,
_ => false,
}
}
fn day_type(day: &Day) -> &'static str {
match day {
Day::Monday | Day::Tuesday | Day::Wednesday
| Day::Thursday | Day::Friday => "weekday",
Day::Saturday | Day::Sunday => "weekend",
}
}
fn main() {
println!("{}", is_weekend(&Day::Saturday)); // true
println!("{}", is_weekend(&Day::Wednesday)); // false
println!("{}", day_type(&Day::Friday)); // weekday
}
Simple enough. But or patterns get more interesting when combined with other features.
Or Patterns With Destructuring
You can use | inside nested patterns, as long as all alternatives bind the same variables with the same types:
enum Expr {
Literal(i64),
Add(Box<Expr>, Box<Expr>),
Sub(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
Neg(Box<Expr>),
}
fn count_operations(expr: &Expr) -> usize {
match expr {
Expr::Literal(_) => 0,
Expr::Add(a, b) | Expr::Sub(a, b) | Expr::Mul(a, b) => {
1 + count_operations(a) + count_operations(b)
}
Expr::Neg(inner) => 1 + count_operations(inner),
}
}
fn main() {
// (3 + 4) * 2
let expr = Expr::Mul(
Box::new(Expr::Add(
Box::new(Expr::Literal(3)),
Box::new(Expr::Literal(4)),
)),
Box::new(Expr::Literal(2)),
);
println!("Operations: {}", count_operations(&expr)); // 2
}
The first arm groups all binary operations together. All three variants bind a and b with the same types, so the or pattern works. When the logic is identical across variants, this cuts duplication dramatically.
Or Patterns in if let and matches!
Or patterns aren’t limited to match:
enum Command {
Start,
Stop,
Pause,
Resume,
Status,
}
fn needs_running_system(cmd: &Command) -> bool {
matches!(cmd, Command::Stop | Command::Pause | Command::Status)
}
fn main() {
let cmd = Command::Pause;
if let Command::Start | Command::Resume = cmd {
println!("Starting up...");
}
// The matches! macro is the cleanest for boolean checks
println!("Needs running: {}", needs_running_system(&cmd));
}
The matches! macro is syntactic sugar for match value { pattern => true, _ => false }. I use it constantly — it’s the cleanest way to do pattern-based boolean checks.
@ Bindings: Name What You Match
Sometimes you need to match a specific pattern and bind the whole value (or a subexpression) to a variable. That’s what @ does:
fn describe_age(age: u32) -> String {
match age {
n @ 0..=2 => format!("{} — infant", n),
n @ 3..=12 => format!("{} — child", n),
n @ 13..=17 => format!("{} — teenager", n),
n @ 18..=64 => format!("{} — adult", n),
n @ 65.. => format!("{} — senior", n),
}
}
fn main() {
for age in [0, 5, 15, 30, 70] {
println!("{}", describe_age(age));
}
}
Without @, you’d need to match the range and then reference the original value separately. With @, the variable n captures the value that matched the range. Both the range check and the binding happen in one place.
@ With Enum Variants
This gets more useful with enums:
#[derive(Debug)]
enum Packet {
Data { seq: u32, payload: Vec<u8> },
Ack { seq: u32 },
Heartbeat,
Error { code: u16, message: String },
}
fn process_packet(packet: &Packet) {
match packet {
p @ Packet::Data { seq, .. } if *seq > 1000 => {
println!("High-sequence data packet: {:?}", p);
}
Packet::Data { seq, payload } => {
println!("Data seq={}, {} bytes", seq, payload.len());
}
Packet::Ack { seq } => {
println!("Ack for seq={}", seq);
}
p @ Packet::Error { .. } => {
// Bind the whole variant for logging, but don't destructure
println!("Error packet: {:?}", p);
}
Packet::Heartbeat => {
println!("Heartbeat");
}
}
}
fn main() {
let packets = vec![
Packet::Data { seq: 1, payload: vec![1, 2, 3] },
Packet::Data { seq: 1500, payload: vec![4, 5] },
Packet::Ack { seq: 1 },
Packet::Error { code: 500, message: "internal".to_string() },
Packet::Heartbeat,
];
for p in &packets {
process_packet(p);
}
}
The p @ Packet::Data { seq, .. } pattern does three things: matches the Data variant, destructures seq out of it, and binds the entire Packet::Data to p. This is perfect for logging — you want the whole value for debug output but specific fields for logic.
Nested @ Bindings
You can put @ at any level of nesting:
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
#[derive(Debug)]
enum Location {
Known(Point),
Unknown,
}
fn classify_location(loc: &Location) -> String {
match loc {
Location::Known(p @ Point { x: 0, y: 0 }) => {
format!("At origin: {:?}", p)
}
Location::Known(p @ Point { x: 0, .. }) => {
format!("On Y-axis: {:?}", p)
}
Location::Known(p @ Point { y: 0, .. }) => {
format!("On X-axis: {:?}", p)
}
Location::Known(p) => {
format!("At {:?}", p)
}
Location::Unknown => "Unknown location".to_string(),
}
}
fn main() {
let locations = vec![
Location::Known(Point { x: 0, y: 0 }),
Location::Known(Point { x: 0, y: 5 }),
Location::Known(Point { x: 3, y: 0 }),
Location::Known(Point { x: 3, y: 4 }),
Location::Unknown,
];
for loc in &locations {
println!("{}", classify_location(loc));
}
}
The p @ binding captures the Point while the inner pattern matches specific field values. You get both the structural match and the bound variable.
Rest Patterns: Ignore What You Don’t Need
The .. pattern (which you’ve already seen in struct destructuring) works in more places than most people realize.
In Tuple Patterns
fn first_and_last(data: &(i32, i32, i32, i32, i32)) -> (i32, i32) {
let (first, .., last) = data;
(*first, *last)
}
fn starts_with_zero(data: &(i32, i32, i32)) -> bool {
matches!(data, (0, ..))
}
fn main() {
let data = (1, 2, 3, 4, 5);
let (first, last) = first_and_last(&data);
println!("First: {}, Last: {}", first, last);
println!("{}", starts_with_zero(&(0, 42, 99))); // true
println!("{}", starts_with_zero(&(1, 0, 0))); // false
}
.. matches any number of elements in the middle. You can only use it once per tuple pattern — (a, .., b, .., c) is ambiguous and won’t compile.
In Struct Patterns
struct Connection {
host: String,
port: u16,
timeout_ms: u64,
pool_size: u32,
tls: bool,
retry_count: u8,
}
fn connection_label(conn: &Connection) -> String {
let Connection { host, port, tls, .. } = conn;
if *tls {
format!("tls://{}:{}", host, port)
} else {
format!("tcp://{}:{}", host, port)
}
}
fn main() {
let conn = Connection {
host: "db.example.com".to_string(),
port: 5432,
timeout_ms: 5000,
pool_size: 10,
tls: true,
retry_count: 3,
};
println!("{}", connection_label(&conn));
}
The .. in struct patterns is forgiving — when new fields get added to the struct, functions using .. don’t need to change. That’s a double-edged sword. Sometimes you want the compiler to force you to handle a new field.
In Slice Patterns
Slice patterns with .. are available since Rust 1.42 and they’re underused:
fn describe_slice(s: &[i32]) -> String {
match s {
[] => "empty".to_string(),
[x] => format!("single element: {}", x),
[first, second] => format!("pair: ({}, {})", first, second),
[first, .., last] => {
format!("{} elements, from {} to {}", s.len(), first, last)
}
}
}
fn starts_with_header(bytes: &[u8]) -> bool {
matches!(bytes, [0xFF, 0xD8, 0xFF, ..]) // JPEG magic bytes
}
fn main() {
println!("{}", describe_slice(&[]));
println!("{}", describe_slice(&[42]));
println!("{}", describe_slice(&[1, 2]));
println!("{}", describe_slice(&[1, 2, 3, 4, 5]));
let jpeg_header = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00];
let not_jpeg = vec![0x89, 0x50, 0x4E, 0x47]; // PNG
println!("JPEG: {}", starts_with_header(&jpeg_header));
println!("JPEG: {}", starts_with_header(¬_jpeg));
}
Slice patterns with .. are particularly powerful for protocol parsing, byte-level processing, and argument handling.
Combining Everything
Here’s a realistic example that uses or patterns, @ bindings, and rest patterns together:
#[derive(Debug)]
enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
Fatal,
}
#[derive(Debug)]
struct LogEntry {
level: LogLevel,
message: String,
timestamp: u64,
source: String,
}
fn should_alert(entry: &LogEntry) -> Option<String> {
match entry {
// Fatal always alerts, bind the whole entry for context
e @ LogEntry { level: LogLevel::Fatal, .. } => {
Some(format!("CRITICAL: {:?}", e))
}
// Error from specific sources alerts
LogEntry {
level: LogLevel::Error,
source,
message,
..
} if source == "payment" || source == "auth" => {
Some(format!("Alert from {}: {}", source, message))
}
// Warn or Error with specific keywords
LogEntry {
level: LogLevel::Warn | LogLevel::Error,
message,
..
} if message.contains("timeout") || message.contains("connection refused") => {
Some(format!("Infrastructure issue: {}", message))
}
// Everything else — no alert
_ => None,
}
}
fn main() {
let entries = vec![
LogEntry {
level: LogLevel::Fatal,
message: "out of memory".to_string(),
timestamp: 1000,
source: "system".to_string(),
},
LogEntry {
level: LogLevel::Error,
message: "card declined".to_string(),
timestamp: 1001,
source: "payment".to_string(),
},
LogEntry {
level: LogLevel::Warn,
message: "connection refused to cache".to_string(),
timestamp: 1002,
source: "cache".to_string(),
},
LogEntry {
level: LogLevel::Info,
message: "user logged in".to_string(),
timestamp: 1003,
source: "auth".to_string(),
},
];
for entry in &entries {
match should_alert(entry) {
Some(alert) => println!("ALERT: {}", alert),
None => println!("OK: {:?}", entry.message),
}
}
}
The or pattern LogLevel::Warn | LogLevel::Error inside the struct pattern is the key move here — it matches two log levels with a single arm, and the guard adds the keyword check. Without or patterns, you’d need two nearly identical arms.
Pattern Syntax Quick Reference
Here’s the quick reference I keep in my head:
_— match anything, don’t bindx— match anything, bind toxA | B— matchAorBx @ pattern— match pattern, bind whole value tox..— match remaining fields or elementsref x— bind by referenceref mut x— bind by mutable reference(a, b, ..)— match tuple, ignore trailing elements[first, .., last]— match slice, ignore middle elementsStruct { field, .. }— match struct, ignore other fields
You don’t need to memorize all of this upfront. But knowing these exist means you’ll recognize opportunities to simplify your code when they come up.
Next up: enums carrying data. We’ve been using them in examples already, but it’s time to talk about how to design them — modeling real domains with algebraic data types.