Enums and pattern matching
Closed sets of variants, each with their own data. The most undervalued feature in mainstream languages.
A Rust enum is a tagged union: one of a fixed set of variants, each variant possibly carrying its own data. Combined with the match expression and the compiler's exhaustiveness check, enums close entire categories of bugs that languages without them leave open. If you take one idea from this chapter: when the set of cases is known and finite, use an enum.
Defining variants
The simplest enum lists variants with no data:
pub enum Status {
Active,
Suspended,
Deleted,
}That is already useful: the type system rejects Status::Banished or arbitrary strings. But enums get interesting when variants carry data:
pub enum Event {
UserCreated { id: u64, email: String },
UserDeleted(u64),
Heartbeat,
ConfigChanged { key: String, value: serde_json::Value },
}Three variant styles, all in the same enum:
- Struct-style (
UserCreated { id, email }) for named fields when the variant carries multiple pieces. - Tuple-style (
UserDeleted(u64)) for one or a few positional pieces. - Unit (
Heartbeat) for variants with no data.
Each variant is its own constructor. The compiler tracks which variant you have at all times.
The Option<T> enum
Rust does not have null. It has Option<T>, which is just:
pub enum Option<T> {
None,
Some(T),
}That is the whole definition. The reason Rust feels safer than languages with null is not magic, it is this enum plus the exhaustiveness check. You cannot accidentally dereference None, because you cannot dereference an Option at all. You must unwrap it through match, if let, ?, or one of the helper methods.
fn find_user(id: u64) -> Option<User> { /* ... */ }
match find_user(42) {
Some(user) => println!("found {}", user.email),
None => println!("not found"),
}match and exhaustiveness
The compiler refuses to compile a match that does not cover every variant:
match event {
Event::UserCreated { id, email } => create(*id, email),
Event::UserDeleted(id) => delete(*id),
// error[E0004]: non-exhaustive patterns: `Heartbeat` and `ConfigChanged { .. }` not covered
}That error is the whole point. Add a variant to Event, every match against it fails to compile until you handle the new case. This is refactor-safety the compiler hands you for free.
You can collapse cases with _:
match event {
Event::UserCreated { id, email } => create(*id, email),
_ => {} // ignore everything else
}That works, but it disables the exhaustiveness check for future variants. If you genuinely want to ignore the rest, _ is fine. If you want the compiler to remind you when a variant appears, list each variant by name and assign {} explicitly.
Patterns inside match
Patterns are surprisingly powerful:
match event {
Event::UserCreated { id: 0, .. } => panic!("zero id leaked"),
Event::UserCreated { id, email } if email.ends_with("@example.com") => {
log_internal(*id);
}
Event::UserCreated { id, .. } => create(*id),
Event::UserDeleted(id) if *id > 1000 => audit_delete(*id),
Event::UserDeleted(id) => delete(*id),
Event::Heartbeat | Event::ConfigChanged { .. } => {} // multi-pattern
}You can match literal values, destructure with { field, .. } to ignore the rest, add if-guards, and join patterns with |. The compiler still verifies exhaustiveness.
if let and let else
Sometimes you only care about one variant. if let is shorthand:
if let Some(user) = find_user(42) {
println!("{}", user.email);
}
// Equivalent to:
// match find_user(42) {
// Some(user) => println!("{}", user.email),
// None => {}
// }For early-exit on the absent case, use let else:
fn process(id: u64) -> Result<(), Error> {
let Some(user) = find_user(id) else {
return Err(Error::NotFound(id));
};
// user is now in scope for the rest of the function
do_stuff(&user);
Ok(())
}The else block must diverge: return, break, continue, panic. The bound value (user here) is available in the rest of the scope, not nested inside a block. This is the cleanest way to express "I expect this, bail if not."
When agents reach for traits and they should have used enums
This is the single most common Rust mistake from agents:
trait Event: Send + Sync {
fn kind(&self) -> &str;
fn timestamp(&self) -> u64;
}
struct UserCreated { /* ... */ }
impl Event for UserCreated { /* ... */ }
struct UserDeleted { /* ... */ }
impl Event for UserDeleted { /* ... */ }
fn handle(e: Box<dyn Event>) {
match e.kind() {
"user_created" => /* downcast somehow */,
"user_deleted" => /* downcast somehow */,
_ => {}
}
}pub enum Event {
UserCreated { id: u64, email: String, at: u64 },
UserDeleted { id: u64, at: u64 },
}
fn handle(e: &Event) {
match e {
Event::UserCreated { id, email, .. } => create(*id, email),
Event::UserDeleted { id, .. } => delete(*id),
}
}The bad version splits the data across types, hides the tag behind a string, and prevents the compiler from helping. The good version is shorter, faster (no heap allocation, no virtual dispatch), and the compiler enforces that every match handles every variant.
Use a trait when the set of implementations is genuinely open and the caller does not need to know which variant it has. Use an enum when the set is closed and you want to act differently per case. For most domain types, the set is closed.
A related smell: a struct with a kind: String field that drives all the logic. That is an enum begging to be born.
Mini-pattern: state machines
Enums shine for state machines. Each variant carries the data appropriate to that state:
pub enum Connection {
Disconnected,
Connecting { since: Instant },
Connected { socket: TcpStream, since: Instant },
Errored { reason: String, retries: u32 },
}A Disconnected connection cannot carry a TcpStream. An Errored one carries a reason. The data shape and the state are the same thing. Compare to a struct with a state: String field plus six Option<...> fields, half of them always None: that is the structure that should have been an enum.