Enums in Rust aren’t just for representing distinct states; they are a fundamental building block for creating state machines that are provably safe at compile time.

Let’s see this in action. Imagine a simple TrafficLight state machine.

enum TrafficLight {
    Red,
    Yellow,
    Green,
}

impl TrafficLight {
    fn next_state(&self) -> Self {
        match self {
            TrafficLight::Red => TrafficLight::Green,
            TrafficLight::Green => TrafficLight::Yellow,
            TrafficLight::Yellow => TrafficLight::Red,
        }
    }
}

fn main() {
    let mut light = TrafficLight::Red;
    println!("Initial state: Red");

    light = light.next_state();
    println!("Next state: Green"); // This should print Green

    light = light.next_state();
    println!("Next state: Yellow"); // This should print Yellow

    light = light.next_state();
    println!("Next state: Red"); // This should print Red
}

This enum TrafficLight defines all possible states. The next_state method, using a match expression, ensures that every possible transition is explicitly handled. If we tried to add a new state to the enum but forgot to update the match arms, the Rust compiler would flag it as an error, preventing a runtime bug.

The core problem this solves is the "state explosion" or "invalid state" bug. In many languages, a variable representing a state could accidentally be assigned a value that doesn’t correspond to any valid state, or a function might be called on an object in a state it doesn’t support. Rust’s enums, combined with match, eliminate this by making the set of valid states and the valid transitions between them explicit and enforced by the compiler. The match statement is exhaustive: if you define N variants in your enum, the match must cover all N variants. If you add a new variant later, the compiler will remind you that your match is no longer exhaustive.

Consider a more complex example, like a network connection state.

enum ConnectionState {
    Disconnected,
    Connecting(String), // Stores the address we're trying to connect to
    Connected(String),  // Stores the established connection details
    Error(String),      // Stores an error message
}

impl ConnectionState {
    fn display_status(&self) {
        match self {
            ConnectionState::Disconnected => println!("Status: Disconnected"),
            ConnectionState::Connecting(addr) => println!("Status: Connecting to {}...", addr),
            ConnectionState::Connected(details) => println!("Status: Connected with details: {}", details),
            ConnectionState::Error(msg) => println!("Status: Error - {}", msg),
        }
    }
}

fn main() {
    let mut conn = ConnectionState::Disconnected;
    conn.display_status();

    conn = ConnectionState::Connecting("192.168.1.100".to_string());
    conn.display_status();

    // Simulate a successful connection
    if let ConnectionState::Connecting(addr) = conn {
        conn = ConnectionState::Connected(format!("Socket: tcp@{}", addr));
    }
    conn.display_status();

    // Simulate an error
    conn = ConnectionState::Error("Connection timed out".to_string());
    conn.display_status();
}

Here, the enum variants can carry associated data. Connecting carries the address, and Connected carries connection details. The match statement can destructure these variants, allowing you to access the associated data directly. This makes state machines incredibly expressive and type-safe. If a function is designed to only operate on a Connected state, you can enforce that by having it accept ConnectionState::Connected(details) as an argument, or by using match to ensure you only proceed when in that specific state.

The power of this approach lies in its ability to model complex workflows with absolute certainty. You can represent any real-world process that has distinct stages and transitions. When you define a state machine using Rust enums, you are essentially creating a contract. The compiler is the enforcer of that contract. Any deviation from the defined states or transitions will be caught before your code even runs, not during an obscure edge case in production.

One of the most powerful, yet often overlooked, aspects of this pattern is how it interacts with ownership and borrowing. When you move a value into an enum variant that owns data, like ConnectionState::Connected(String), that String is now owned by the ConnectionState enum. If you want to transition out of that state and extract the String, you must explicitly do so, often through a match arm that consumes the enum. This prevents accidental use-after-move errors or dangling references that are common in manual state management.

The next step is often to integrate these state machines with asynchronous operations, where the state might change based on external events or the results of Futures.

Want structured learning?

Take the full Rust course →