Skip to main content
Rust · lesson 12 of 30
Rust beginner Lesson 12 of 30

Enums in Rust

Learn Rust enums that carry data, Option<T> and Result<T, E>, exhaustive matching, and enums as state machines, with real compiler output.

By SkillByExample · Updated

Basic Enums

An enum defines a type by listing its possible variants. The simplest enums have variants with no data, similar to enums in other languages. They are useful any time a value must be one of a fixed set of named possibilities — directions, states, categories, command types.

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

fn describe(d: Direction) -> &'static str {
    match d {
        Direction::North => "heading north",
        Direction::South => "heading south",
        Direction::East  => "heading east",
        Direction::West  => "heading west",
    }
}

fn main() {
    let dir = Direction::North;
    println!("{}", describe(dir));

    for d in [Direction::South, Direction::East, Direction::West] {
        println!("{}", describe(d));
    }
}
$ rustc enums_basic_enums.rs && ./enums_basic_enums
heading north
heading south
heading east
heading west

Delete one arm — say Direction::West — and the program stops compiling: match must be exhaustive, so adding a variant to an enum later turns every match that forgot it into a compile error rather than a silent fall-through.

Enums with Data

What makes Rust enums genuinely powerful is that each variant can carry its own data — and different variants can carry different types and amounts of data. This is called an algebraic data type, and it lets a single enum model a full family of related but structurally different values. No separate wrapper structs needed.

#[derive(Debug)]
enum Shape {
    Circle { radius: f64 },                    // named fields (like a struct)
    Rectangle { width: f64, height: f64 },     // named fields
    Triangle(f64, f64, f64),                   // positional fields (like a tuple)
}

impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
            Shape::Rectangle { width, height } => width * height,
            Shape::Triangle(a, b, c) => {
                // Heron's formula — area from three side lengths
                let s = (a + b + c) / 2.0;
                (s * (s - a) * (s - b) * (s - c)).sqrt()
            }
        }
    }
}

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

    for shape in &shapes {
        println!("{:?} => area: {:.2}", shape, shape.area());
    }
}
$ rustc enums_enums_with_data.rs && ./enums_enums_with_data
Circle { radius: 5.0 } => area: 78.54
Rectangle { width: 4.0, height: 6.0 } => area: 24.00
Triangle(3.0, 4.0, 5.0) => area: 6.00

Option<T> — Safe Nullability

Rust has no null. This is a deliberate design choice: null is the source of countless bugs and crashes across nearly every language that has it. Instead, the standard library provides Option<T>, which makes the possibility of “no value” explicit in the type system. The compiler forces you to handle both cases, eliminating null pointer exceptions entirely.

// Option<T> is defined in the standard library as:
enum Option<T> {
    Some(T), // a value is present
    None,    // no value
}
fn find_first_even(numbers: &[i32]) -> Option<i32> {
    for &n in numbers {
        if n % 2 == 0 {
            return Some(n); // wrap the found value
        }
    }
    None // signal absence explicitly — not null, not -1, not a magic number
}

fn main() {
    let nums = vec![1, 3, 5, 4, 7];

    // match forces you to handle both cases
    match find_first_even(&nums) {
        Some(n) => println!("Found even: {}", n),
        None    => println!("No even numbers"),
    }

    // Option has many convenience methods for common patterns
    let result = find_first_even(&nums);
    println!("{}", result.unwrap());              // panics if None
    println!("{}", result.unwrap_or(0));          // returns 0 if None
    println!("{}", result.unwrap_or_else(|| -1)); // calls the closure if None
    println!("{:?}", result.map(|n| n * 2));      // transforms the value inside Some
    println!("{}", result.is_some());
    println!("{}", result.is_none());

    let odds: Vec<i32> = vec![1, 3, 5];
    println!("{}", find_first_even(&odds).unwrap_or(0));
}
$ rustc enums_option_t_safe_nullability_2.rs && ./enums_option_t_safe_nullability_2
Found even: 4
4
4
4
Some(8)
true
false
0

The three 4s look redundant, but each method differs only in what happens on None — which the last line shows: with no even number, unwrap_or(0) falls back to 0. map keeps the Some wrapper, so it prints Some(8), not 8.

if let with Option

When you only care about the Some case and want to ignore None, if let is more concise than a full match:

fn main() {
    let config_value: Option<&str> = Some("debug");

    // if let — only runs the block when the pattern matches
    if let Some(value) = config_value {
        println!("Config: {}", value);
    }
    // None case is silently ignored — that is exactly the intent here

    fn get_username(id: u32) -> Option<String> {
        let users = vec!["alice", "bob", "charlie"];
        users.get(id as usize).map(|s| s.to_string())
    }

    println!("{:?}", get_username(1));
    println!("{:?}", get_username(9));
}
$ rustc enums_if_let_with_option.rs && ./enums_if_let_with_option
Config: debug
Some("bob")
None

Result<T, E> — Error Handling

Result<T, E> is Rust’s primary mechanism for representing operations that can fail. Unlike exceptions, errors are values — they appear in function signatures, they must be handled explicitly, and the compiler ensures you do not silently discard them. This makes error paths visible and auditable.

// Result<T, E> is defined in the standard library as:
enum Result<T, E> {
    Ok(T),  // success with a value of type T
    Err(E), // failure with an error of type E
}
use std::num::ParseIntError;

fn parse_and_double(s: &str) -> Result<i32, ParseIntError> {
    let n = s.trim().parse::<i32>()?; // ? propagates Err to the caller automatically
    Ok(n * 2)                         // wrap the success value in Ok
}

fn main() {
    match parse_and_double("21") {
        Ok(n)  => println!("Result: {}", n),
        Err(e) => println!("Error: {}", e),
    }

    match parse_and_double("abc") {
        Ok(n)  => println!("Result: {}", n),
        Err(e) => println!("Error: {}", e),
    }

    // Result has similar convenience methods to Option
    let r: Result<i32, &str> = Ok(10);
    println!("{}", r.unwrap());           // panics if Err
    println!("{}", r.unwrap_or(0));       // returns 0 if Err
    println!("{:?}", r.map(|n| n + 1));   // transforms the Ok value
    println!("{}", r.is_ok());
}
$ rustc enums_result_t_e_error_handling_2.rs && ./enums_result_t_e_error_handling_2
Result: 42
Error: invalid digit found in string
10
10
Ok(11)
true

The error message comes from ParseIntError’s Display impl — parse_and_double never wrote it, ? just passed it up unchanged.

Enums as State Machines

Enums are the natural data structure for state machines because each state can carry exactly the data it needs — no more, no less. Impossible states become unrepresentable: you cannot accidentally be Connected without a host name, or Connecting without a port.

#[derive(Debug)]
enum ConnectionState {
    Disconnected,
    Connecting { host: String, port: u16 },
    Connected { host: String, bytes_sent: u64, bytes_received: u64 },
    Error(String),
}

impl ConnectionState {
    fn connect(host: &str, port: u16) -> Self {
        ConnectionState::Connecting {
            host: host.to_string(),
            port,
        }
    }

    fn is_connected(&self) -> bool {
        // matches! macro — concise single-pattern check
        matches!(self, ConnectionState::Connected { .. })
    }

    fn describe(&self) -> String {
        match self {
            ConnectionState::Disconnected => "idle".to_string(),
            ConnectionState::Connecting { host, port } => format!("dialing {}:{}", host, port),
            ConnectionState::Connected { host, bytes_sent, bytes_received } =>
                format!("up to {} ({} out / {} in)", host, bytes_sent, bytes_received),
            ConnectionState::Error(msg) => format!("failed: {}", msg),
        }
    }
}

fn main() {
    let mut state = ConnectionState::Disconnected;
    println!("{:?} -> {}", state, state.describe());

    state = ConnectionState::connect("example.com", 443);
    println!("{}", state.describe());

    state = ConnectionState::Connected {
        host: "example.com".to_string(),
        bytes_sent: 512,
        bytes_received: 2048,
    };
    println!("{} | connected: {}", state.describe(), state.is_connected());

    state = ConnectionState::Error("connection reset".to_string());
    println!("{} | connected: {}", state.describe(), state.is_connected());
}
$ rustc enums_enums_as_state_machines.rs && ./enums_enums_as_state_machines
Disconnected -> idle
dialing example.com:443
up to example.com (512 out / 2048 in) | connected: true
failed: connection reset | connected: false

Each describe arm can only touch the fields that exist in that state: there is no port to read once you are Connected, and no byte counters while still Connecting.

Methods on Enums

Enums can have impl blocks just like structs, giving you methods and associated functions on the enum type:

#[derive(Debug, PartialEq)]
enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

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

    fn is_silver(&self) -> bool {
        // Penny is copper; everything else is silver-coloured
        !matches!(self, Coin::Penny)
    }
}

fn main() {
    let coins = vec![Coin::Quarter, Coin::Dime, Coin::Penny, Coin::Nickel];
    let total: u32 = coins.iter().map(|c| c.value_in_cents()).sum();
    println!("total: {} cents", total);

    let silver = coins.iter().filter(|c| c.is_silver()).count();
    println!("silver-coloured coins: {}", silver);
    println!("first is a quarter: {}", coins[0] == Coin::Quarter);
}
$ rustc enums_methods_on_enums.rs && ./enums_methods_on_enums
total: 41 cents
silver-coloured coins: 3
first is a quarter: true

coins[0] == Coin::Quarter compiles only because of #[derive(PartialEq)] — enums get no == by default.

The matches! Macro

matches! is a concise way to test whether a value matches a specific pattern, returning a bool. It is much shorter than writing a full match when you only need a true/false answer, especially with guards.

#[allow(dead_code)] // East and West are never constructed in this demo
enum Direction {
    North,
    South,
    East,
    West,
}

fn main() {
    let val = Some(42);
    println!("{}", matches!(val, Some(x) if x > 0));
    println!("{}", matches!(val, Some(x) if x > 100));
    println!("{}", matches!(val, None));

    let dir = Direction::North;
    // Match multiple variants with | — true if either matches
    println!("{}", matches!(dir, Direction::North | Direction::South));
}
$ rustc enums_the_matches_macro.rs && ./enums_the_matches_macro
true
false
false
true

The guard is part of the pattern: Some(42) matches Some(x), but if x > 100 fails, so the second line is false.

Practice

  1. Delete the Direction::West arm from describe and call describe(Direction::West). What does the compiler report?

    Solution
    error[E0004]: non-exhaustive patterns: `Direction::West` not covered

    The help text even writes the missing arm for you: Direction::West => todo!(),. This is the payoff of exhaustive matching — add a fifth variant to an enum, and every match that doesn’t handle it fails to build.

  2. Add a Square { side: f64 } variant to Shape and extend area. What prints for Shape::Square { side: 3.0 }?

    Solution

    Add the arm Shape::Square { side } => side * side,. It prints Square { side: 3.0 } => area: 9.00 — #[derive(Debug)] formats the new variant with no further work.

  3. Predict what println!("{:?}", parse_and_double(...)) prints for " 7 ", "99999999999" and "".

    Solution
    Ok(14)
    Err(ParseIntError { kind: PosOverflow })
    Err(ParseIntError { kind: Empty })

    trim() removes the spaces, so " 7 " parses. The long number overflows i32, and the empty string has no digits at all — two different error kinds behind the same ParseIntError type. {:?} shows the kind; {} would show the human message.

  4. With let result = Some(4);, what do result.filter(|n| *n > 10) and result.filter(|n| *n > 1) return? And with let r: Result<i32, &str> = Err("boom");, what do r.unwrap_or(0) and r.map(|n| n + 1) give?

    Solution

    None, Some(4), 0, and Err("boom"). filter turns a Some into None when the predicate fails; map on an Err doesn’t run the closure at all and passes the error through untouched.

Frequently Asked Questions

How are Rust enums different from enums in other languages?
Rust enums are algebraic data types — each variant can carry different types and amounts of data. They are much more powerful than C/Java enums which are just named integers.
What is Option<T>?
Option<T> is the standard way to represent an optional value. It has two variants: Some(T) when a value is present, and None when it isn't. It replaces null/nil from other languages.
What is Result<T, E>?
Result<T, E> represents the outcome of an operation that might fail. Ok(T) means success with a value, Err(E) means failure with an error. It is Rust's primary error handling mechanism.