How to Implement the State Pattern in Rust with Enums

Implement the State Pattern in Rust using an enum for states and a trait to define behavior for each state.

The vending machine problem

You are building a controller for a vending machine. The machine behaves differently depending on whether it has accepted a coin, is dispensing a snack, or is out of stock. In JavaScript or Python, you might reach for a boolean flag or a string variable to track the state, then sprinkle if/else blocks everywhere. Rust offers a cleaner path. You encode the state directly into the type system using enums. The compiler forces you to handle every state transition. You cannot accidentally dispense a snack when the machine is broken.

Enums as state machines

The State Pattern solves a common problem: an object changes its behavior when its internal state changes. In many languages, you implement this with a class hierarchy or a collection of flags. Rust does something different. You use an enum to represent the state machine. Each variant of the enum is a distinct state. The compiler guarantees you handle every variant. You cannot forget a state. The type itself carries the invariant.

Think of a physical light switch. It has positions. It cannot be "half-on" unless you break it. An enum is like that switch. The value is the state. If the enum is Red, the light is red. There is no hidden boolean that contradicts the variant. This property is often called "making impossible states impossible." If a state cannot be represented by the enum, it cannot happen at runtime.

Minimal example: Traffic light cycle

Start with a simple cycle. A traffic light moves from Red to Green to Yellow and back. The state changes permanently when the light advances. You model this by consuming the old state and producing a new one.

#[derive(Debug)]
/// Represents the possible states of a traffic light.
enum TrafficLight {
    Red,
    Yellow,
    Green,
}

impl TrafficLight {
    /// Returns the next state in the cycle.
    /// Takes ownership of `self` because the light changes state permanently.
    fn next_state(self) -> TrafficLight {
        match self {
            // Red turns to Green.
            TrafficLight::Red => TrafficLight::Green,
            // Green turns to Yellow.
            TrafficLight::Green => TrafficLight::Yellow,
            // Yellow turns back to Red.
            TrafficLight::Yellow => TrafficLight::Red,
        }
    }
}

fn main() {
    let light = TrafficLight::Red;
    // `next_state` consumes the old state and produces a new one.
    let next = light.next_state();
    println!("{:?}", next);
}

The method signature fn next_state(self) is deliberate. Passing self by value moves the enum into the function. The match destructures the value. Rust checks that every variant is covered. If you add a Broken variant later, the code will not compile until you handle it. The compiler acts as a state machine validator.

How the compiler guards your transitions

Exhaustiveness is the core safety feature. When you write a match on an enum, Rust verifies that you have covered all variants. This prevents logic gaps. If your domain evolves and a new state appears, the compiler highlights every location that needs updating. You cannot ship code that ignores a state.

This also applies to data extraction. Enums can carry data. A state might hold a socket, an error message, or a configuration. The compiler ensures you only access data that exists in the current state. You cannot read a socket when the connection is closed. The type system enforces the invariant.

Realistic example: Document editor with data

Real state machines often carry context. A document editor has states like Draft, Saved, and Unsaved. The Unsaved state needs to hold the modified content. You model this by attaching data to the variant.

#[derive(Debug, PartialEq)]
/// Tracks the persistence state of a document.
enum DocumentState {
    Draft,
    Saved,
    Unsaved(String),
}

struct Document {
    state: DocumentState,
}

impl Document {
    /// Creates a new document in the Draft state.
    fn new() -> Self {
        Document {
            state: DocumentState::Draft,
        }
    }

    /// Edits the document, moving it to the Unsaved state.
    fn edit(&mut self, content: &str) {
        // Any edit marks the document as unsaved.
        self.state = DocumentState::Unsaved(content.to_string());
    }

    /// Attempts to save the document.
    /// Returns an error if there is nothing to save.
    fn save(&mut self) -> Result<(), &str> {
        match self.state {
            // Only Unsaved documents can be saved.
            DocumentState::Unsaved(ref content) => {
                // Simulate saving to disk.
                // In real code, this would perform I/O.
                println!("Saving content: {}", content);
                self.state = DocumentState::Saved;
                Ok(())
            }
            // Saved and Draft documents have nothing to save.
            DocumentState::Saved | DocumentState::Draft => {
                Err("Nothing to save")
            }
        }
    }
}

fn main() {
    let mut doc = Document::new();
    doc.edit("Hello, Rust!");
    
    match doc.save() {
        Ok(()) => println!("Document saved."),
        Err(e) => println!("Error: {}", e),
    }
}

The save method uses &mut self to allow mutation. The match inspects the current state. The Unsaved arm extracts the content using ref content to borrow the string without moving it. This allows the method to read the data while keeping the enum intact for the state update. The other arms combine Saved and Draft because they share the same behavior. Rust lets you group variants when the logic is identical.

Pitfalls and compiler errors

State machines in Rust are robust, but there are common traps.

If you add a variant and forget to update a match, the compiler rejects the code with E0004 (non-exhaustive patterns). The error message lists the missing variants. This is a feature. It forces you to consider how the new state affects every operation. Do not suppress this error with a wildcard _ unless you have a deliberate fallback strategy. A wildcard hides missing logic.

// BAD: Hides missing states.
match state {
    DocumentState::Unsaved(_) => { /* ... */ }
    _ => { /* What happens if it's Saved? */ }
}

Another trap is trying to move data out of a borrowed enum. If you match on &self and try to extract an owned value, you hit E0507 (cannot move out of borrowed content). You must borrow the data or clone it.

fn get_content(state: &DocumentState) -> String {
    match state {
        // This works: clone the string.
        DocumentState::Unsaved(content) => content.clone(),
        // This would fail: cannot move `content` out of `*state`.
        // DocumentState::Unsaved(content) => content,
        _ => String::new(),
    }
}

The community convention is to derive Debug on every enum. You will need it for logging and testing. You will also likely want PartialEq and Eq to assert state transitions in tests. Add these derives at the top of the enum definition. It saves time later.

Decision: Enums vs traits vs flags

Choosing the right representation depends on your domain. Use the structure that matches the constraints.

Use an enum for state when the set of states is closed and known at compile time. Use an enum with associated data when different states carry different payloads, like a socket in Connected or an error string in Error. Use a trait-based state pattern when you need dynamic dispatch or allow third-party plugins to add new states at runtime. Use a struct with boolean flags when properties are independent and can coexist, like a window being both resizable and maximized.

Enums are zero-cost. The compiler inlines the state checks. Traits introduce indirection and a small runtime overhead. Prefer enums unless you have a specific reason for flexibility. The performance gain is usually negligible, but the safety gain is significant.

Where to go next