$elementary.dev
[Courses][About]|
$elementary.dev

Text-first, project-driven courses for developers who want to level up by building real things.

CoursesAbout
Stay Updated

Get notified about new courses and articles.

// No spam, unsubscribe anytime

© 2026 elementary.dev
PrivacyTerms
// Course

Elementary Rust

9/11
  • 01Hello, Rust
  • 02Variables and Types
  • 03Functions and Control Flow
  • 04Why Memory Management Matters
  • 05Ownership: Rust's Solution
  • 06Borrowing and References
  • 07Slices
  • 08Structs
  • 09Enums and Pattern Matching
  • 10Organizing Rust Projects
  • 11Collections
courses/elementary-rust/09-enums-matching

Enums and Pattern Matching

Define types with variants and handle them with Rust's powerful match expressions.

~25 min|#Lesson 9/11

Enums and Pattern Matching

Enums let you define a type by enumerating its possible variants. Combined with pattern matching, they become one of Rust's most expressive features.

// Info-What you'll learn

By the end of this lesson, you'll understand how to define enums with associated data, use Option<T> to handle absence safely, and leverage match expressions for exhaustive pattern handling.

Enums: More Than Constants

enum IpAddrKind {
    V4,
    V6,
}

let four = IpAddrKind::V4;
let six = IpAddrKind::V6;

Enums in Rust are what you would expect in any language: a set of possible variants for a type. But Rust enums have an additional power: each variant can carry its own data.

Enums with Associated Data

enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));

Each variant is independent. V4 holds four bytes. V6 holds a String. They're completely different shapes, yet both are valid IpAddr values.

You can put anything inside enum variants: structs, tuples, other enums, or nothing at all. Each variant defines its own mini-type.

enum Message {
    Quit,                       // no data
    Move { x: i32, y: i32 },    // named fields like a struct
    Write(String),              // single String
    ChangeColor(i32, i32, i32), // tuple of three i32s
}

This is remarkably flexible. One enum, four completely different data shapes.

The Option Enum

Rust has no null. This is intentional.

Tony Hoare, who invented null references in 1965, later called it his "billion-dollar mistake." The problem? Any value might secretly be null, and the compiler can't help you. You have to remember to check everywhere.

// Warning-The billion-dollar mistake

Tony Hoare: "I call it my billion-dollar mistake. It has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years."

Rust's solution is elegant. Instead of allowing any value to be null, it has a special type for "might be absent":

enum Option<T> {
    None,
    Some(T),
}

The <T> is a generic type parameter. Option<i32> can hold a number or nothing. Option<String> can hold a string or nothing.

let some_number = Some(5);
let absent: Option<i32> = None;

Notice we didn't need to write Option::Some(5). The Option variants are so common that they're brought into scope automatically. You can use Some and None directly.

// Info-Explicit absence

Option makes absence explicit in the type system. If a function returns Option<User>, you know it might return None. If it returns User, you know it always returns a user. The compiler forces you to handle both cases.

Here's the key insight: you cannot use an Option<T> as if it were a T. The compiler won't let you.

let x: i32 = 5;
let y: Option<i32> = Some(5);

// This won't compile!
// let sum = x + y;

To use the value inside Some, you must first extract it. This forces you to handle the None case. No more forgetting to check for null.

// Challenge

Quick Check: Option Variants

What are the two variants of Option?

$

Accessing Enum Values

So how do you get data out of an enum? Rust gives you three main tools:

  1. match - exhaustive pattern matching
  2. if let - when you care about one variant
  3. let else - extract or bail (Rust 1.65+)

For chaining operations on Option and Result, you can also use methods like .map(), .unwrap_or(), and .ok_or(). We'll see these when we cover error handling.

Let's explore the pattern matching tools.

match Expressions

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

A match expression compares a value against a series of patterns. When a pattern matches, the corresponding code runs.

Each pattern and its code is called an arm. The => separates the pattern from the code to execute.

// Danger-match is exhaustive

You must handle every possible variant. If you forget one, the compiler will reject your code. This prevents bugs where you forget to handle a case.

Try removing one arm from the coin example. The compiler will tell you exactly which variant you forgot.

Patterns That Bind Values

Patterns can extract values from enum variants:

fn describe_option(opt: Option<i32>) {
    match opt {
        Some(inner) => println!("Got: {inner}"),
        None => println!("Nothing here"),
    }
}

When opt is Some(42), the pattern Some(inner) matches and binds inner to 42. You can then use inner in that arm's code.

This is how you safely extract values from Option. The match guarantees you only access the value when it exists.

fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        None => None,
        Some(i) => Some(i + 1),
    }
}

let five = Some(5);
let six = plus_one(five);    // Some(6)
let none = plus_one(None);   // None
// Tip-Match is an expression

Like most things in Rust, match is an expression. It returns a value. All arms must return the same type.

Catch-All Patterns

Sometimes you only care about a few specific values:

fn move_player(dice_roll: u8) {
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        other => move_spaces(other),  // Catch all, use value
    }
}

The other pattern matches anything and binds the value to other. This must come last since patterns are checked in order.

If you don't need the value, use _:

match dice_roll {
    3 => add_fancy_hat(),
    7 => remove_fancy_hat(),
    _ => (),  // Do nothing for all other values
}

The _ pattern matches anything but doesn't bind. The unit value () means "do nothing."

if let: One Pattern

Sometimes match feels verbose when you only care about one variant:

// This works, but it's wordy
match config_max {
    Some(max) => println!("Max is {max}"),
    _ => (),
}

You're only doing something for Some. The _ => () arm is just noise.

Enter if let:

if let Some(max) = config_max {
    println!("Max is {max}");
}

Same behavior, less ceremony.

// Warning-if let is one construct

Don't read if let as "if" receiving a value from "let". It's a single syntactic construct: if let PATTERN = EXPRESSION { ... }. The pattern is on the left, the value on the right.

You can add an else clause for when the pattern doesn't match:

if let Some(max) = config_max {
    println!("Max is {max}");
} else {
    println!("No max configured");
}

Think of if let as syntax sugar for a match with one meaningful arm and a catch-all.

let else: Extract or Bail

Rust 1.65 introduced let else for a common pattern: extract a value or return early.

fn get_count(text: &str) -> usize {
    let Some(count) = parse_number(text) else {
        return 0;  // Must diverge: return, break, panic, etc.
    };
    // count is available here!
    count * 2
}

The crucial difference from if let:

  • if let: binding only exists inside the if block
  • let else: binding exists after the statement
// With if let - n only exists inside the block
if let Some(n) = some_value {
    println!("{n}");  // n exists here
}
// n doesn't exist here

// With let else - n exists after
let Some(n) = some_value else {
    return;
};
println!("{n}");  // n exists here!
// Tip-Diverging else block

The else block in let else must diverge. It can't just fall through. Use return, break, continue, or panic!. This guarantees that if execution continues past the statement, the binding is valid.

let else shines in functions with multiple early-return checks:

fn process_user(input: &str) -> Result<User, Error> {
    let Some(id) = parse_id(input) else {
        return Err(Error::InvalidId);
    };

    let Some(user) = fetch_user(id) else {
        return Err(Error::UserNotFound);
    };

    let Some(verified) = verify_user(&user) else {
        return Err(Error::VerificationFailed);
    };

    Ok(verified)
}

Each check extracts a value or returns early. The happy path flows straight down.

Putting It All Together

Enums and pattern matching work together beautifully. Enums define the shape of your data. Pattern matching destructures it safely.

enum Command {
    Start { id: u32 },
    Stop,
    Pause { duration: u64 },
}

fn execute(cmd: Command) {
    match cmd {
        Command::Start { id } => {
            println!("Starting process {id}");
        }
        Command::Stop => {
            println!("Stopping all processes");
        }
        Command::Pause { duration } => {
            println!("Pausing for {duration}ms");
        }
    }
}

The compiler ensures you handle every command type. Add a new variant? Every match must be updated. Forget a case? Compilation fails.

This is why Rust code tends to have fewer runtime surprises. The type system catches mistakes before they become bugs.

// Challenge

Quick Check: Match Exhaustiveness

If you match on an enum with 4 variants, what's the minimum number of arms you need?

$

Summary

Rust enums are algebraic data types. Each variant can hold different data, making them far more powerful than enums in most languages.

Option<T> replaces null with explicit, type-safe absence handling. You can't accidentally forget to handle the missing case.

Pattern matching with match is exhaustive. The compiler ensures you handle every variant. Use if let when you only care about one pattern. Use let else when you want to extract or bail early.

Together, enums and pattern matching let you model complex domains precisely and handle every case explicitly. No surprises at runtime.

// Tip-Coming up

In the next lesson, we'll explore how to organize your Rust code into modules and crates. As your programs grow, good structure becomes essential.

<-[prev]Structs[next]->Organizing Rust Projects
Progress: 0%
// On this page
  • Enums: More Than Constants
  • Enums with Associated Data
  • The Option Enum
  • Accessing Enum Values
  • match Expressions
  • Patterns That Bind Values
  • Catch-All Patterns
  • if let: One Pattern
  • let else: Extract or Bail
  • Putting It All Together
  • Summary