Enums & Pattern Matching
Define enums that carry data, understand Option<T> as Rust's replacement for null, and use match and if let to handle every case safely.
Introduction
Enums let you define a type that can be one of several distinct variants — and in Rust, each variant can carry its own data. Combined with the `match` expression, enums become Rust's primary tool for modeling "one of several possibilities" safely, with the compiler forcing you to handle every case.
This lesson covers defining enums, enums that carry data, the `match` expression, and `Option<T>` — the enum that replaces null entirely in Rust.
- How to define an enum and its variants.
- How enum variants can carry different types and amounts of data.
- How the match expression exhaustively handles every possible case.
- Why Rust has no null, and how Option<T> represents "maybe a value" instead.
- When to use if let as a shorthand for match.
Defining Enums
An enum, defined with the `enum` keyword, lists a set of named variants that a value of that type can be. For example, an enum `Direction` might have variants `North`, `South`, `East`, and `West` — a value of type `Direction` is always exactly one of those four, never more, never less, and never something else.
Enums That Carry Data
Unlike enums in many other languages, Rust enum variants can hold their own data, and different variants can hold different types entirely. This makes Rust enums closer to what other languages call "tagged unions" or "algebraic data types" — genuinely more expressive than a simple list of named constants.
The match Expression
`match` compares a value against a series of patterns and runs the code for the first one that matches. Its defining feature is exhaustiveness: the compiler requires every possible case to be handled, either explicitly or with a catch-all `_` pattern — if you add a new enum variant later and forget to update a match on it elsewhere, your code simply won't compile until you do.
Option<T>: Rust Has No Null
Many languages use `null` or `nil` to represent "no value," which is a notorious source of runtime crashes when code forgets to check for it. Rust has no null at all. Instead, the standard library defines `Option<T>`, an enum with two variants: `Some(T)` when a value is present, and `None` when it isn't. Because `Option<T>` is a distinct type from `T`, the compiler will not let you use a possibly-absent value as if it were guaranteed to exist — you must explicitly handle both cases, usually with `match` or `if let`.
if let for Simple Cases
When you only care about one specific pattern and want to ignore every other case, a full `match` can feel like unnecessary ceremony. `if let` is a shorthand for exactly that situation — it matches one pattern and runs a block if it matches, optionally with an `else` for everything else.
Code Example
enum Shape { Circle(f64), Rectangle(f64, f64), Triangle(f64, f64, f64),}
fn area(shape: &Shape) -> f64 { match shape { Shape::Circle(radius) => std::f64::consts::PI * radius * radius, Shape::Rectangle(width, height) => width * height, Shape::Triangle(a, b, c) => { let s = (a + b + c) / 2.0; (s * (s - a) * (s - b) * (s - c)).sqrt() } }}
fn main() { let shapes = vec![Shape::Circle(3.0), Shape::Rectangle(4.0, 5.0)];
for shape in &shapes { println!("Area: {:.2}", area(shape)); }}Click Run to see what this code prints.
Here is `Option<T>` and `if let` in action, modeling a lookup that might not find anything:
fn find_user(id: u32) -> Option<&'static str> { match id { 1 => Some("Alice"), 2 => Some("Bob"), _ => None, }}
fn main() { match find_user(2) { Some(name) => println!("Found user: {}", name), None => println!("No user found"), }
if let Some(name) = find_user(1) { println!("if let match: {}", name); }}Click Run to see what this code prints.
Common Mistakes
- Calling `.unwrap()` on an `Option` that might be `None`, causing a runtime panic instead of handling the missing case.
- Adding a catch-all `_` arm out of habit, which silently swallows new enum variants you add later instead of forcing you to handle them.
- Treating enum variants like separate struct types rather than pattern-matching on the enum itself.
- Forgetting that match arm bodies with more than one statement need curly braces, while single expressions don't.
Best Practices
- Let match exhaustiveness work for you — when you add a new enum variant, let the compiler point you to every place that needs updating.
- Avoid a catch-all `_` arm when you actually want the compiler to force you to handle new cases explicitly.
- Prefer `if let`/`while let` for the common case of caring about only one variant.
- Reach for combinators like `.map()`, `.unwrap_or()`, and `.and_then()` on Option before reaching for `.unwrap()`.
Frequently Asked Questions
No. Rust deliberately has no null value; the Option<T> enum represents the possibility of absence instead, and the compiler forces you to handle both cases explicitly.
Only when the arm's body is more than one statement; single-expression arms can omit them.
A feature that lets you match on a reference (like &Shape) without manually writing ref patterns — the compiler infers the borrowing for you automatically.
They're much more powerful — Rust enum variants can carry different types and amounts of data, closer to what other languages call tagged unions or algebraic data types.
Key Takeaways
- Enums define a type that is exactly one of several named variants, and each variant can carry its own data.
- match compares a value against patterns exhaustively — the compiler requires every case to be handled.
- Option<T> (Some(T) / None) replaces null entirely, forcing explicit handling of absence.
- if let is a concise shorthand for match when you only care about a single pattern.
Summary
You now know how to model "one of several possibilities" with enums, safely handle every case with match, and represent absence with Option<T> instead of null. Next, you'll cover Rust's control flow constructs — if/else as an expression, loops, and how loop can return a value.