Enums and pattern matching
In one sentence
Section titled “In one sentence”A Rust enum is a type with a fixed list of variants, each with its own data, and match must handle every variant, or the code does not compile.
The Java you know
Section titled “The Java you know”A Java enum is a fixed list of constants. Each constant has the same fields. For “one of several shapes of data”, Java 21 has sealed interfaces with records, and switch with patterns:
Java
sealed interface Shipment permits Pending, Shipped, Delivered, Returned {}
record Pending() implements Shipment {}
record Shipped(String carrier, String tracking) implements Shipment {}
record Delivered(String signedBy) implements Shipment {} // signedBy can be null
record Returned(String reason) implements Shipment {}
static String statusLine(Shipment shipment) { return switch (shipment) { case Pending p -> "waiting for pickup"; case Shipped(String carrier, String tracking) -> "with " + carrier + ", tracking " + tracking; case Delivered(String signedBy) when signedBy != null -> "delivered, signed by " + signedBy; case Delivered d -> "delivered"; case Returned(String reason) -> "returned: " + reason; };}Rust
#[derive(Debug, Clone, PartialEq, Eq)]pub enum Shipment { Pending, Shipped { carrier: String, tracking: String }, Delivered { signed_by: Option<String> }, Returned { reason: String },}The Rust enum is the sealed interface and its records in one declaration. Elixir developers know the same idea as tagged tuples: {:shipped, carrier, tracking}.
The Rust you will see
Section titled “The Rust you will see”Each variant of an enum can be:
- a unit, with no data:
Pending; - a tuple, with unnamed fields:
Some(T),Err(E); - a struct, with named fields:
Shipped { carrier, tracking }.
A value of the enum is exactly one variant at a time. Option<T> and Result<T, E> are enums like this, from the standard library.
match takes a value apart by its variant:
pub fn status_line(shipment: &Shipment) -> String { match shipment { Shipment::Pending => "waiting for pickup".to_string(), Shipment::Shipped { carrier, tracking } => format!("with {carrier}, tracking {tracking}"), Shipment::Delivered { signed_by: Some(name), } => format!("delivered, signed by {name}"), Shipment::Delivered { signed_by: None } => "delivered".to_string(), Shipment::Returned { reason } => format!("returned: {reason}"), }}- Line 2:
matchis an expression: its value is the value of the arm that matches. Here it is the return value of the function. - Line 4: The pattern names the fields, and binds them to variables.
carrierandtrackingare&Stringhere, becauseshipmentis a reference. - Line 5-7: A nested pattern: this arm matches only a
Deliveredwhosesigned_byisSome. The name inside is bound toname. - Line 8: The other half of
Delivered. Together, lines 5 to 8 cover all cases of that variant. - Line 9: There is no
_ =>arm. Every variant is listed, so the compiler can check that none is missing.
match is exhaustive
Section titled “match is exhaustive”If a match does not handle every variant, it does not compile. This is the most useful property of enums for a reviewer. When someone adds a variant, the compiler lists every match that must change:
use concepts::enums_and_pattern_matching::Shipment;fn is_done(shipment: &Shipment) -> bool { match shipment { Shipment::Delivered { .. } => true, Shipment::Pending | Shipment::Shipped { .. } => false, } // error E0004: non-exhaustive patterns: `&Shipment::Returned { .. }` not covered}Java’s switch over a sealed interface is also exhaustive. The difference is that Rust code uses enums and match everywhere, not only in new code.
How to read it
Section titled “How to read it”Patterns
Section titled “Patterns”Patterns appear in match, if let, while let, let, and function parameters. The parts you will see:
pub fn shipping_cents(weight_grams: u32, country: &str, express: bool) -> u64 { match (weight_grams, country, express) { (0, _, _) => 0, // a literal (_, "CH", true) => 1_800, // `_` matches anything (1..=499, "CH", false) => 700, // a range (w @ 500..=1_999, "CH", false) => 900 + u64::from(w / 500) * 100, // bind the value to `w` (w, _, _) if w > 30_000 => 15_000, // a guard (_, "DE" | "AT" | "FR", _) => 1_600, // alternatives _ => 2_500, }}| Pattern | Meaning |
|---|---|
0, "CH", true |
A literal value. |
_ |
Anything, and do not bind it. |
1..=499 |
A range, both ends included. |
"DE" | "AT" |
Either alternative. |
w @ 500..=1_999 |
Match the range, and bind the value to w. |
x if x > 30_000 |
A guard: the arm matches only if the condition is true. |
Shipped { carrier, .. } |
A struct variant. .. ignores the other fields. |
(a, b, c) |
A tuple. Here it matches three values at once. |
The arms are tested from top to bottom, and the first one that matches wins, as in a Java switch. Order matters: (_, "CH", true) on the second line catches express shipments before the weight arms.
Destructuring also works in let, for structs:
pub struct Line { pub sku: String, pub quantity: u32, pub unit_cents: u64,}
pub fn line_total(line: &Line) -> u64 { let Line { quantity, unit_cents, .. // ignore the other fields } = line; u64::from(*quantity) * unit_cents}if let, while let, matches!
Section titled “if let, while let, matches!”When only one variant matters, a full match is long. These are the short forms:
pub fn tracking_number(shipment: &Shipment) -> Option<&str> { if let Shipment::Shipped { tracking, .. } = shipment { Some(tracking) } else { None }}
pub fn is_open(shipment: &Shipment) -> bool { matches!(shipment, Shipment::Pending | Shipment::Shipped { .. })}
pub fn drain(queue: &mut Vec<Shipment>) -> usize { let mut delivered = 0; while let Some(shipment) = queue.pop() { if let Shipment::Delivered { .. } = shipment { delivered += 1; } } delivered}if let Pattern = value { ... } else { ... }: one variant, and anelsefor the others.let Pattern = value else { return ... };: one variant, or leave the function (see Option, not null).while let Some(x) = stack.pop() { ... }: loop while the pattern matches.matches!(value, Pattern): abool. Likeinstanceoffor variants.
State machines
Section titled “State machines”An enum is the natural type for a state machine. Each state is a variant, with only the data that belongs to that state. Transitions consume the old state and return the new one:
#[derive(Debug, PartialEq, Eq)]pub enum Order { Draft { lines: Vec<u64>, }, Placed { lines: Vec<u64>, total_cents: u64, }, Paid { total_cents: u64, payment_id: String, }, Cancelled,}
impl Order { /// Consumes the old state and returns the new one, so the old state cannot be reused. pub fn place(self) -> Result<Self, Self> { match self { Self::Draft { lines } if !lines.is_empty() => { let total_cents = lines.iter().sum(); Ok(Self::Placed { lines, total_cents }) } other => Err(other), // not a draft, or an empty draft: no change } }
pub fn pay(self, payment_id: &str) -> Result<Self, Self> { match self { Self::Placed { total_cents, .. } => Ok(Self::Paid { total_cents, payment_id: payment_id.to_string(), }), other => Err(other), } }}place(self) takes the order by value, so the caller cannot keep using the draft after it is placed. An order that is Paid has a payment_id, and an order that is Draft cannot have one: the type makes the wrong combinations impossible. Here other => Err(other) is a deliberate catch-all: “any other state cannot make this transition”. That is different from a wildcard that hides new variants, because no variant needs its own handling.
What good looks like
Section titled “What good looks like”- An enum for every “one of” in the domain: payment states, shipment states, delivery options, error kinds.
- Data inside the variant that needs it. No
Optionfields that are only valid in some states. matchwithout wildcards on your own enums, so the compiler finds every place that a new variant affects.if letandmatches!for one variant,matchfor several.- Transitions that consume
selffor state machines.
Red flags in AI output
Section titled “Red flags in AI output”boolflags or string constants instead of an enum.express: bool, overnight: boolorstatus: Stringwith"paid". See S15:boolflags or string constants instead of an enum.- A wildcard arm
_ =>on your own enum. It hides the next variant that someone adds. See S16: Wildcard_ =>match arm that hides new variants. - A struct with many
Optionfields that are set or not set depending on the state. See S17: Struct with manyOptionfields that are really states. matchthat only callsunwrap, or that has one arm with real work and one that panics.if x.is_some() { ... x.unwrap() }instead ofif let Some(x).- A Java-style
enumwith a separateHashMapof data per constant, instead of data in the variants.
- S15Severity: Medium
boolflags or string constants instead of an enumStates encoded as flags or strings, so invalid combinations are possible. - S16Severity: MediumWildcard
_ =>match arm that hides new variantsThe compiler cannot tell you when a new variant is not handled. - S17Severity: MediumStruct with many
Optionfields that are really statesWhich fields are set depends on a hidden state that an enum should express.
Better vs worse
Section titled “Better vs worse”The task: model the state of a shipment, and print a status line.
WorseTwo flags and four
Optionfields. Many combinations are impossible in reality but valid for the compiler.pub struct Shipment {pub shipped: bool,pub delivered: bool,pub carrier: Option<String>,pub tracking: Option<String>,pub signed_by: Option<String>,pub return_reason: Option<String>,}pub fn status_line(s: &Shipment) -> String {if s.return_reason.is_some() {format!("returned: {}", s.return_reason.as_deref().unwrap_or(""))} else if s.delivered {"delivered".to_string()} else if s.shipped {format!("with {}", s.carrier.as_deref().unwrap_or("unknown carrier"))} else {"waiting for pickup".to_string()}}Run in Playground (opens the Rust Playground in a new tab) BetterAn enum, and an exhaustive
match. Each state has exactly its own data.pub fn status_line(shipment: &Shipment) -> String {match shipment {Shipment::Pending => "waiting for pickup".to_string(),Shipment::Shipped { carrier, tracking } => format!("with {carrier}, tracking {tracking}"),Shipment::Delivered {signed_by: Some(name),} => format!("delivered, signed by {name}"),Shipment::Delivered { signed_by: None } => "delivered".to_string(),Shipment::Returned { reason } => format!("returned: {reason}"),}}Run in Playground (opens the Rust Playground in a new tab)
The “worse” struct accepts “delivered, but never shipped” and “shipped, with no carrier”. The test worse_allows_impossible_states builds one. Its status_line must decide in which order to test the flags, and it silently ignores fields that do not fit. The order of the if chain is now part of the business logic, and nobody wrote it down.
With the enum, each state is one variant with its own fields. Adding a state (for example Lost) makes the compiler list every match that must handle it. The enum is in the “Rust you will see” section above.
A struct with Option fields is correct when the fields are really independent: an optional nickname and an optional phone number can each be present or not, in any combination.
Check yourself
Section titled “Check yourself”What is the Java 21 equivalent of a Rust enum whose variants hold data?
Show the answer
A sealed interface with one record for each variant, and a
switchwith record patterns. Rust puts it all in oneenumdeclaration.Someone adds a variant to an enum. What does the compiler do?
Show the answer
It rejects every
matchon that enum that does not handle the new variant (error E0004), unless thematchhas a wildcard_arm. Then the new variant goes to the wildcard silently.What does
w @ 500..=1_999mean in a pattern?Show the answer
Match a value from 500 to 1999 (both included), and bind it to the name
wso that the arm can use it.A struct has
paid: bool,paid_at: Option<String>andrefund_id: Option<String>. What do you suggest?Show the answer
An enum with one variant per state, for example
Unpaid,Paid { at },Refunded { at, refund_id }. Then “paid, but no date” and “refunded, but not paid” cannot exist.