Enum and pattern matching in Rust
1. Overview
In many languages, enums are just named lists of constants. In Rust, each variant of an enum can also carry its own data. When combined with match, the compiler forces you to handle all possible cases.
This article goes from how to define enums, through match, guards, binding with @, to Option, Result, if let, let else, and matches!. After reading, you’ll be able to model business states using enums and avoid common mistakes.
This article uses Rust 1.65 and above (needed for let else) and requires no external libraries.
2. Defining enums with data
Suppose you need to describe the status of an order:
#[derive(Debug, Clone, PartialEq)]
enum OrderStatus {
Pending,
Paid(u64),
Shipped { carrier: String, tracking: String },
Cancelled { reason: String },
}
fn main() {
let statuses = vec![
OrderStatus::Pending,
OrderStatus::Paid(250_000),
OrderStatus::Shipped {
carrier: "GHN".to_string(),
tracking: "GHN123".to_string(),
},
OrderStatus::Cancelled { reason: "Out of stock".to_string() },
];
for s in &statuses {
println!("{:?}", s);
}
}
This enum has three kinds of variants:
Pendingis a unit variant, carrying no data.Paid(u64)is a tuple variant, carrying the amount paid by position.Shipped { .. }andCancelled { .. }are struct variants, carrying named field data.
#[derive(Debug, Clone, PartialEq)] allows printing values with {:?}, cloning, and comparing with ==. The comparison part is useful when writing tests.
Pending
Paid(250000)
Shipped { carrier: "GHN", tracking: "GHN123" }
Cancelled { reason: "Out of stock" }
The key point: a Pending order cannot accidentally have a tracking code. Data exists only in the variant that needs it. With a struct approach using many Option fields, you don’t get this guarantee.
3. match basics and exhaustiveness
match compares a value against each pattern in order from top to bottom. The first matching branch runs:
fn describe(status: &OrderStatus) -> String {
match status {
OrderStatus::Pending => "Waiting for payment".to_string(),
OrderStatus::Paid(amount) => format!("Paid {amount} VND"),
OrderStatus::Shipped { carrier, tracking } => {
format!("Shipping via {carrier}, code {tracking}")
}
OrderStatus::Cancelled { reason } => format!("Cancelled: {reason}"),
}
}
A few points to note:
statushas type&OrderStatus. Thanks to default binding modes, you write patterns as if working with owned values. The variablesamount,carrier,trackingare references (&u64,&String), so no data is moved.matchis an expression. All branches must return the same type, hereString.
Now try removing the Cancelled branch. The compiler will refuse to build:
error[E0004]: non-exhaustive patterns: `&OrderStatus::Cancelled { .. }` not covered
--> src/main.rs:12:11
|
12 | match status {
| ^^^^^^ pattern `&OrderStatus::Cancelled { .. }` not covered
This is the greatest value of match. When you add a new variant, say Refunded, every match that doesn’t handle it will report an error. You don’t have to manually hunt for places to fix.
4. Advanced patterns: tuples, |, guards, ranges, and @
Patterns aren’t just for enums. You can match a tuple to check multiple conditions at once:
enum CustomerTier {
Regular,
Silver,
Gold,
}
fn discount(tier: &CustomerTier, total: u64) -> u64 {
match (tier, total) {
(CustomerTier::Gold, _) => total / 10,
(CustomerTier::Silver, t @ 1_000_000..) => t / 20,
(CustomerTier::Silver | CustomerTier::Regular, t) if t >= 2_000_000 => 50_000,
_ => 0,
}
}
fn main() {
println!("{}", discount(&CustomerTier::Gold, 300_000));
println!("{}", discount(&CustomerTier::Silver, 1_200_000));
println!("{}", discount(&CustomerTier::Regular, 2_500_000));
println!("{}", discount(&CustomerTier::Regular, 100_000));
}
Each branch works as follows:
_matches any value and binds no variable.t @ 1_000_000..checks thattotalis in the range 1 million and above, while binding that value tot.CustomerTier::Silver | CustomerTier::Regularuses|to combine multiple patterns into one branch.if t >= 2_000_000is a match guard. It’s an additional condition checked only after the pattern matches._ => 0is the default branch. It’s required becauseu64has infinitely many values.
30000
60000
50000
0
With struct variants, you use .. to skip fields you don’t care about:
fn shipping_fee(status: &OrderStatus) -> u64 {
match status {
OrderStatus::Shipped { carrier, .. } if carrier == "GHN" => 22_000,
OrderStatus::Shipped { .. } => 30_000,
_ => 0,
}
}
Note: The compiler does not use guards when checking exhaustiveness. If you write only a
Shipped { .. } if ...branch without an unguardedShippedbranch, you still need to add another branch to cover all cases.
5. Option and Result are just enums
The standard library defines Option<T> (consisting of Some(T) and None) and Result<T, E> (consisting of Ok(T) and Err(E)). Because they are enums, you handle them with exactly the tools above:
fn find_customer(id: u32) -> Option<&'static str> {
match id {
1 => Some("An"),
2 => Some("Binh"),
_ => None,
}
}
fn parse_quantity(input: &str) -> Result<u32, String> {
match input.trim().parse::<u32>() {
Ok(0) => Err("Quantity must be greater than 0".to_string()),
Ok(n) => Ok(n),
Err(e) => Err(format!("Invalid: {e}")),
}
}
fn main() {
println!("{:?}", find_customer(2));
println!("{:?}", find_customer(9));
println!("{:?}", parse_quantity(" 3 "));
println!("{:?}", parse_quantity("0"));
println!("{:?}", parse_quantity("abc"));
}
Ok(0) is a nested pattern. It matches when the result is Ok and the inner value equals 0. Since Ok(0) comes before Ok(n), zero is caught before reaching the general case.
Some("Binh")
None
Ok(3)
Err("Quantity must be greater than 0")
Err("Invalid: invalid digit found in string")
Rust has no null. Every value that “might be absent” must go through Option, and the compiler forces you to handle the None case.
6. Short syntax: if let, let else, while let, matches!
Writing full match statements gets verbose when you only care about one variant. Rust has a few shorter syntaxes for this case.
6.1. if let
fn tracking_code(status: &OrderStatus) -> Option<&str> {
if let OrderStatus::Shipped { tracking, .. } = status {
Some(tracking.as_str())
} else {
None
}
}
if let runs the block when the pattern matches. The else part is optional. This syntax doesn’t force you to handle all cases, so use it only when the remaining variants truly don’t matter.
6.2. let else
let else fits the “early return” pattern: extract data if it matches, otherwise exit immediately.
fn charge(status: &OrderStatus) -> Result<u64, String> {
let OrderStatus::Paid(amount) = status else {
return Err("Order not yet paid".to_string());
};
Ok(*amount)
}
After the let line, the variable amount is available for the rest of the function without nesting additional blocks. The else block must exit the current flow via return, break, continue, or panic!.
6.3. while let and matches!
fn main() {
let mut queue = vec![
OrderStatus::Paid(100_000),
OrderStatus::Pending,
OrderStatus::Paid(400_000),
];
let paid = queue
.iter()
.filter(|s| matches!(s, OrderStatus::Paid(_)))
.count();
println!("Number of paid orders: {paid}");
while let Some(status) = queue.pop() {
println!("Processing: {status:?}");
}
}
matches!(value, pattern)returnsbool. This macro is very useful infilterorassert!.while let Some(status) = queue.pop()loops untilpop()returnsNone.
Number of paid orders: 2
Processing: Paid(400000)
Processing: Pending
Processing: Paid(100000)
7. Attaching methods to enums: modeling valid state transitions
Enums can have impl just like structs. A useful pattern is representing valid state transitions:
impl OrderStatus {
fn ship(self, carrier: &str, tracking: &str) -> Result<OrderStatus, String> {
match self {
OrderStatus::Paid(_) => Ok(OrderStatus::Shipped {
carrier: carrier.to_string(),
tracking: tracking.to_string(),
}),
other => Err(format!("Cannot ship order in state {other:?}")),
}
}
fn cancel(self, reason: &str) -> Result<OrderStatus, String> {
match self {
OrderStatus::Pending | OrderStatus::Paid(_) => Ok(OrderStatus::Cancelled {
reason: reason.to_string(),
}),
other => Err(format!("Cannot cancel order in state {other:?}")),
}
}
fn is_final(&self) -> bool {
matches!(self, OrderStatus::Cancelled { .. })
}
}
A few points to note:
shipandcanceltakeselfby value, so the old state is consumed. You can’t continue using the old state variable after transitioning.- The
other => ...branch binds the entire remaining value to the variableotherto include in the error message. is_finalis read-only, so it takes&self.
8. Writing tests for core behavior
Rust has a built-in test framework. You place tests in a #[cfg(test)] module in the same file:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paid_order_can_be_shipped() {
let result = OrderStatus::Paid(250_000).ship("GHN", "GHN123");
assert_eq!(
result,
Ok(OrderStatus::Shipped {
carrier: "GHN".to_string(),
tracking: "GHN123".to_string(),
})
);
}
#[test]
fn pending_order_cannot_be_shipped() {
let result = OrderStatus::Pending.ship("GHN", "X");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Pending"));
}
#[test]
fn cancelled_order_is_final() {
let status = OrderStatus::Pending.cancel("Customer changed mind").unwrap();
assert!(status.is_final());
assert!(matches!(status, OrderStatus::Cancelled { ref reason } if reason == "Customer changed mind"));
}
}
assert_eq! works because the enum derives PartialEq and Debug. When a test fails, Rust prints both expected and actual values. In the last test, ref reason binds the field by reference, so status isn’t moved into matches!.
cargo test
running 3 tests
test tests::cancelled_order_is_final ... ok
test tests::paid_order_can_be_shipped ... ok
test tests::pending_order_cannot_be_shipped ... ok
test result: ok. 3 passed; 0 failed; 0 ignored
9. Common mistakes and when not to use enums
Overusing _ loses exhaustiveness. If you write _ => ... for an enum you own, adding a new variant later will be silently ignored by the compiler. The new variant falls straight into the default branch, which is usually not what you want. For business enums, explicitly list each variant.
Moving data out of a reference. Writing match *status { OrderStatus::Cancelled { reason } => ... } causes an error:
error[E0507]: cannot move out of `status` as enum variant `Cancelled` which is behind a shared reference
There are three ways to fix it: match directly on status (the reference), use ref reason, or clone the data if you truly need to own it.
Placing the general branch before the specific one. If Ok(n) comes before Ok(0), the Ok(0) branch will never run. The compiler only warns unreachable pattern rather than erroring, so you might miss it.
Overly large variants. The size of an enum equals the size of its largest variant plus tag overhead. If a variant contains a large array, every enum value uses that much memory. Wrap that variant with Box<...>. The clippy::large_enum_variant lint will remind you of this.
Note: If you’re writing a library and want to add variants later without breaking user code, mark the enum with
#[non_exhaustive]. Then code in other crates must have a_branch.
When shouldn’t you use enums? Enums fit when the set of cases is closed and under your control, such as order statuses or error types. If the set is open, like when users of your library need to add new payment methods, use traits (Box<dyn PaymentMethod> or generics). Adding a variant requires modifying the original enum, while traits allow extension from outside.
10. Conclusion
Enums in Rust let each case carry exactly its data. match forces you to handle all cases at compile time. You’ve seen:
- Unit, tuple, and struct variants.
matchwith binding,|, guards, ranges,@, and...OptionandResultas ordinary enums.- Short syntaxes:
if let,let else,while let,matches!. - Using methods on enums to model state transitions, with tests.
The easiest rule to remember: for enums you own, list all variants instead of using _. Then, whenever you add a new variant, the compiler will point out every place you need to fix.