Updated Aug 5, 2026

07. Enums and Pattern Matching

📋 Jump to Takeaways

🎁 A variable that can be a string, a number, a struct with named fields, or nothing at all, and the compiler guarantees you handle every possibility. That's not a loosely typed language being sloppy. That's Rust enums being precise.

Enums with Variants

Enums let you define a type that can be one of several variants. Each variant can hold different data, or none at all.

#[derive(Debug)]
enum Message {
    Quit,                       // unit variant — no data
    Move { x: i32, y: i32 },    // struct variant — anonymous struct with named fields
    Write(String),              // tuple variant — holds one value
    Color(u8, u8, u8),          // tuple variant — holds multiple values
}

fn main() {
    let msg = Message::Move { x: 10, y: 20 };
    println!("{:?}", msg); // Move { x: 10, y: 20 }
}

The #[derive(Debug)] syntax is an attribute, a compile-time annotation that tells the compiler to do something with the item below it. derive specifically auto-generates trait implementations. So #[derive(Debug)] means "generate a Debug implementation for this type so it can be printed with {:?}." Without it, println!("{:?}", msg) won't compile because Rust doesn't know how to format your custom types. You'll want this on almost every struct and enum you write.

What about {}? That uses the Display trait, and Rust can't derive it automatically. You have to implement it yourself:

use std::fmt;

enum Direction {
    Up,
    Down,
}

impl fmt::Display for Direction {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Direction::Up => write!(f, "Up"),
            Direction::Down => write!(f, "Down"),
        }
    }
}

fn main() {
    let d = Direction::Up;
    println!("{}", d); // Up
}

Debug is for developers (quick, derivable). Display is for user-facing output (manual, you control the formatting). That's why most examples use {:?} — one line to enable vs writing a full impl block.

This is far more powerful than enums in C or Java. Each variant is a full data container.

Option and the Absence of Null

Rust has no null. Instead, you use Option<T>, an enum that's either Some(value) or None. It's defined in the standard library using the same enum syntax you just learned:

enum Option<T> {
    Some(T),  // tuple variant — holds one value of any type
    None,     // unit variant — no data
}

<T> is a generic (covered in a later lesson) — it means Option works with any type. Option<String> means "either a String or nothing."

None might look like null with a different name, but the difference is how the compiler treats it. In languages with null, any variable can be null at any time and the compiler won't stop you from using it blindly:

// JavaScript — null hides, compiler won't warn you
let user = getUser(99); // might be null
console.log(user.name); // 💥 runtime crash if null

In Rust, if a function might return nothing, its return type must be Option<T>, not T. And you can't access the inner value without explicitly handling the None case first:

fn find_user(id: u64) -> Option<String> {
    if id == 1 {
        Some(String::from("alice"))
    } else {
        None
    }
}

fn main() {
    let result = find_user(1);
    println!("{:?}", result); // Some("alice")

    if let Some(name) = result {
        println!("{}", name); // alice — the inner value
    }

    let result = find_user(99);
    println!("{:?}", result); // None
}

You can't accidentally use a value that might not exist. The compiler forces you to handle the None case. null hides in any type and crashes at runtime. None announces itself in the type signature and won't compile unless you deal with it. The if let Some(name) = result line extracts the inner value when it's Some, and skips the block when it's None.

Pattern Matching with match

match lets you handle every variant of an enum. It must be exhaustive, so you can't forget a case.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    Color(u8, u8, u8),
}

fn describe(msg: Message) -> String {
    match msg {
        Message::Quit => String::from("Quit signal"),
        Message::Move { x, y } => format!("Move to ({}, {})", x, y),
        Message::Write(text) => format!("Text: {}", text),
        Message::Color(r, g, b) => format!("Color: #{:02x}{:02x}{:02x}", r, g, b),
    }
}

fn main() {
    let m = Message::Move { x: 10, y: 20 };
    println!("{}", describe(m)); // Move to (10, 20)

    let m = Message::Write(String::from("hello"));
    println!("{}", describe(m)); // Text: hello
}

If you add a new variant later, the compiler tells you every match that needs updating. No silent bugs from unhandled cases.

if let for Single Patterns

When you only care about one variant, if let is cleaner than a full match.

The syntax if let Some(user) = name reads as: "if name is Some, pull out the inner value and bind it to a new variable called user." The name user isn't special — it's just a variable you're creating on the spot to hold whatever was inside Some(...).

fn find_user(id: u64) -> Option<String> {
    if id == 1 {
        Some(String::from("alice"))
    } else {
        None
    }
}

fn main() {
    let name = find_user(1); // name is Option<String>

    if let Some(user) = name {
        // user is now a String — the value that was inside Some
        println!("Found: {}", user); // Found: alice
    } else {
        println!("Not found");
    }
}

Use if let when matching a single pattern. Use match when you need to handle multiple variants or want exhaustiveness checking.

Enum Methods

Just like structs, enums can have impl blocks with methods. Here's a shape calculator. Notice that Circle, Rectangle, and Triangle aren't defined anywhere else — they're created right inside the enum. Each variant defines its own data shape, and they only exist as part of Shape:

enum Shape {
    Circle(f64),              // tuple variant — holds one f64 (radius)
    Rectangle(f64, f64),      // tuple variant — holds two f64s (width, height)
    Triangle { base: f64, height: f64 }, // struct variant — named fields
}

impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle(r) => std::f64::consts::PI * r * r, // or `use std::f64::consts::PI;` at the top
            Shape::Rectangle(w, h) => w * h,
            Shape::Triangle { base, height } => 0.5 * base * height,
        }
    }
}

fn main() {
    let shapes = vec![
        Shape::Circle(3.0),
        Shape::Rectangle(4.0, 5.0),
        Shape::Triangle { base: 6.0, height: 3.0 },
    ];

    for s in &shapes { // s is &Shape (reference, vec keeps ownership)
        println!("{:.2}", s.area());
    }
    // 28.27
    // 20.00
    // 9.00
}

Notice std::f64::consts::PI in the code. You can shorten it with use at the top of the file:

use std::f64::consts::PI;

After that, you just write PI directly instead of the full path. You can also bring in multiple items at once:

use std::f64::consts::{PI, E, TAU};

use is purely a shorthand — it doesn't import or load anything, just saves you typing the full path every time.

Enums define what something can be. Impl blocks define what it can do. Together, they replace class hierarchies without the fragility.

Key Takeaways

  • Enums can hold different data in each variant (unit, tuple, struct)
  • #[derive(Debug)] auto-generates debug printing; Display must be implemented manually
  • Option<T> replaces null, and the compiler forces you to handle absence
  • match is exhaustive, so you must handle every variant
  • if let is sugar for matching a single pattern
  • Enums can have impl blocks with methods, just like structs
  • use shortens module paths but doesn't import or load anything new

🎁 Next up: you'll meet Result<T, E>, another enum just like Option. But instead of "something or nothing," it's "success or error," and the ? operator replaces entire try/catch blocks with a single character.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy