Skip to main content
Rust beginner Lesson 12 of 30

Enums in Rust

Learn Rust enums with data, Option<T>, Result<T,E>, and pattern matching.

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));
}

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());
    }
}

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), // Found even: 4
        None    => println!("No even numbers"),
    }

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

    let empty: Vec<i32> = vec![1, 3, 5];
    println!("{}", find_first_even(&empty).unwrap_or(0)); // 0
}

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)); // Some("bob")
    println!("{:?}", get_username(9)); // 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),  // Result: 42
        Err(e) => println!("Error: {}", e),
    }

    match parse_and_double("abc") {
        Ok(n)  => println!("Result: {}", n),
        Err(e) => println!("Error: {}", e),   // Error: invalid digit found in string
    }

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

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 main() {
    let mut state = ConnectionState::Disconnected;
    println!("{:?}", state);

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

    state = ConnectionState::Connected {
        host: "example.com".to_string(),
        bytes_sent: 0,
        bytes_received: 0,
    };
    println!("connected: {}", state.is_connected()); // true
}

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); // 41
}

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.

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

    let dir = Direction::North;
    // Match multiple variants with | — true if either matches
    println!("{}", matches!(dir, Direction::North | Direction::South)); // true
}

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.