Skip to content

Enums and pattern matching

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.

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}.

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}"),
}
}
  1. Line 2: match is an expression: its value is the value of the arm that matches. Here it is the return value of the function.
  2. Line 4: The pattern names the fields, and binds them to variables. carrier and tracking are &String here, because shipment is a reference.
  3. Line 5-7: A nested pattern: this arm matches only a Delivered whose signed_by is Some. The name inside is bound to name.
  4. Line 8: The other half of Delivered. Together, lines 5 to 8 cover all cases of that variant.
  5. Line 9: There is no _ => arm. Every variant is listed, so the compiler can check that none is missing.

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:

Does not compile: E0004
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.

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
}

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 an else for 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): a bool. Like instanceof for variants.

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.

  • 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 Option fields that are only valid in some states.
  • match without wildcards on your own enums, so the compiler finds every place that a new variant affects.
  • if let and matches! for one variant, match for several.
  • Transitions that consume self for state machines.

The task: model the state of a shipment, and print a status line.

  1. WorseTwo flags and four Option fields. 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)
  2. 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.

  1. 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 switch with record patterns. Rust puts it all in one enum declaration.

  2. Someone adds a variant to an enum. What does the compiler do?

    Show the answer

    It rejects every match on that enum that does not handle the new variant (error E0004), unless the match has a wildcard _ arm. Then the new variant goes to the wildcard silently.

  3. What does w @ 500..=1_999 mean in a pattern?

    Show the answer

    Match a value from 500 to 1999 (both included), and bind it to the name w so that the arm can use it.

  4. A struct has paid: bool, paid_at: Option<String> and refund_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.