Code of the Day
IntermediateTypes & traits

Enums and pattern matching

Model domain states with data-carrying enums and exhaustively handle them with match, if let, and destructuring.

RustIntermediate12 min read
Recommended first
By the end of this lesson you will be able to:
  • Define enums with data-carrying variants
  • Write exhaustive match expressions over enum values
  • Use if let and while let for single-variant checks
  • Destructure nested enums and tuples in patterns

Rust's are not just named integer constants like in C. Each variant can carry its own data — different types, different shapes. Combined with exhaustive pattern matching, they're the tool for modelling every possible state of a domain precisely, with no unrepresented case, no null, and no stringly-typed sentinel values.

Result<T, E> and Option<T> — which you've been using — are both enums defined in the standard library using the same feature you're learning here.

Enums with data

An enum variant can hold any type:

enum Message {
    Quit,                       // no data
    Move { x: i32, y: i32 },   // named fields (like a struct)
    Write(String),              // one unnamed field
    Colour(u8, u8, u8),         // three unnamed fields
}

You construct a variant like a function or struct:

let msg1 = Message::Move { x: 10, y: 20 };
let msg2 = Message::Write(String::from("hello"));
let msg3 = Message::Quit;

This lets you represent a heterogeneous set of values in a single type — something that would require a class hierarchy or union in other languages.

Exhaustive match

is Rust's primary tool for handling enums. It must cover every variant — the compiler rejects non-exhaustive matches:

fn process(msg: Message) {
    match msg {
        Message::Quit => println!("quitting"),
        Message::Move { x, y } => println!("moving to {},{}", x, y),
        Message::Write(text) => println!("writing: {}", text),
        Message::Colour(r, g, b) => println!("colour: {},{},{}", r, g, b),
    }
}

Each arm destructures the variant's data inline. If you add a new variant to Message later, every match in the codebase that doesn't use a wildcard (_) will fail to compile — the compiler finds every unhandled case for you.

This exhaustiveness guarantee is a significant reliability property. The fundamentals track covers the value of making illegal states unrepresentable; Rust enums make that possible at the type level, and match enforces handling at the call site.

Wildcard and binding patterns

Use _ to ignore specific variants or fields:

match msg {
    Message::Write(text) => println!("{}", text),
    _                    => {} // ignore everything else
}

Use other (any name other than _) to capture the value while ignoring its type:

match code {
    200 => println!("OK"),
    404 => println!("Not found"),
    other => println!("Unexpected: {}", other),
}

if let — single-variant shorthand

When you only care about one variant, if let is less noisy than a full match:

let config = Some(String::from("debug"));

if let Some(mode) = config {
    println!("Mode: {}", mode);
}
// nothing printed if config is None

This is sugar for a match with one arm and a _ => {} wildcard. Use it when you genuinely only care about one case; use match when you need to handle several or want exhaustiveness to protect you.

while let — loop until a variant changes

while let drives a loop as long as a pattern matches:

let mut stack = vec![1, 2, 3];

while let Some(top) = stack.pop() {
    println!("{}", top);
}
// prints 3, 2, 1

Nested destructuring

Patterns compose. You can destructure structs inside enum variants, tuples inside structs, and so on:

enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}

fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
        Shape::Rectangle { width, height } => width * height,
    }
}

The pattern Shape::Circle { radius } both matches the variant and binds radius to its value in one step — no separate field access needed.

Patterns that use .. to ignore fields are convenient but bypass the exhaustiveness guarantee for those fields. If you add a field later, existing .. patterns silently continue to compile. Use them deliberately, not as a shortcut to avoid dealing with all data.

Check your understanding

Knowledge check

  1. 1.
    What distinguishes a Rust enum from a C-style enum?
  2. 2.
    A match expression in Rust compiles even if some variants are not handled, as long as the program runs correctly at runtime.
  3. 3.
    When is if let preferable to match?

Do it yourself

Define an enum Command with variants Go(Direction), Stop, and Speak(String) where Direction is another enum (North, South, East, West). Write a function execute(cmd: Command) using match. Add a new variant to Command without a wildcard arm — observe the compiler telling you exactly which match arms need updating.

Where to go next

Enums let you define what your data is. The next lesson on traits lets you define what your data can do — and how to write code that works with any type that implements a given capability.

Finished reading? Mark it complete to track your progress.

On this page