Phase 3Data structures

#10 Enums and pattern matching

enum, match, if let

Defining enums

An enum (enumeration) defines a type that can be one of several variants. Unlike enums in many other languages, Rust enums are extremely powerful:

Simple enum
enum Direction {
    North,
    South,
    East,
    West,
}

let heading = Direction::North;

match heading {
    Direction::North => println!("Going north!"),
    Direction::South => println!("Going south!"),
    Direction::East => println!("Going east!"),
    Direction::West => println!("Going west!"),
}

Each variant is accessed with the :: syntax. Enums are often used with match to handle each case.

Enums with data

The real power of Rust enums is that each variant can hold different types and amounts of data:

Enums with data
enum Command {
    Quit,                        // No data
    MoveTo(i32, i32),            // Two integers
    ChangeColor(String),         // A String
    Write { text: String },      // Named field
}

let commands = vec![
    Command::MoveTo(10, 20),
    Command::ChangeColor(String::from("#ff0000")),
    Command::Quit,
];

for cmd in &commands {
    match cmd {
        Command::Quit => println!("Quitting!"),
        Command::MoveTo(x, y) => println!("Moving to ({x}, {y})"),
        Command::ChangeColor(c) => println!("Changing color to red: {c}"),
        Command::Write { text } => println!("Writing: {text}"),
    }
}

This replaces the need for inheritance or union types in other languages. Each variant can be a completely different shape.

The match expression

match must handle every variant of an enum. The compiler enforces exhaustiveness, so you cannot forget a case:

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

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

// Use _ as a catch-all pattern
fn is_small(coin: &Coin) -> bool {
    match coin {
        Coin::Penny | Coin::Nickel => true,
        _ => false,  // Everything else
    }
}

if let syntax

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

if let
let some_value: Option<i32> = Some(42);

// With match
match some_value {
    Some(v) => println!("The value is: {v}"),
    None => println!("No value"),
}

// With if let — more concise for single patterns
if let Some(v) = some_value {
    println!("The value is: {v}");
} else {
    println!("No value");
}

Common enums: Option and Result

Two of the most important enums in Rust are built into the standard library:

Option<T> — a value or nothing
// Option<T> replaces null in Rust
// enum Option<T> { Some(T), None }

fn find_first_even(numbers: &[i32]) -> Option<i32> {
    for &n in numbers {
        if n % 2 == 0 {
            return Some(n);
        }
    }
    None
}

let nums = [1, 3, 5, 7];
match find_first_even(&nums) {
    Some(n) => println!("Found: {n}"),
    None => println!("Not a number"),
}
Result<T, E> — success or error
// Result<T, E> for operations that can fail
// enum Result<T, E> { Ok(T), Err(E) }

use std::fs;

fn read_config(path: &str) -> Result<String, String> {
    fs::read_to_string(path)
        .map_err(|e| format!("File not found: {path}"))
}

match read_config("config.toml") {
    Ok(content) => println!("Config: {content}"),
    Err(e) => println!("{e}"),
}

// The ? operator propagates errors automatically
fn load_config() -> Result<String, std::io::Error> {
    let content = fs::read_to_string("config.toml")?;
    Ok(content)
}

Rust has no null value. Instead, Option forces you to handle the absence of a value explicitly. Result forces you to handle errors. This eliminates entire categories of bugs at compile time.

Your turn

Try running the enums demo with cargo run and cargo check in the terminal below:

terminal — cargo
user@stemlegacy:~/enums-demo$