Enums as State Machines

PhantomData and Marker Types encoded states into type parameters so invalid transitions fail to compile, powerful, but the state is fixed at compile time. Most state is dynamic: a connection is disconnected, then connecting, then connected, decided at runtime. Rust's enums are the natural tool, because a data-carrying enum can hold different data in each state and a match forces you to handle every one. This tutorial is about using enums to make illegal states unrepresentable at runtime: the boolean-soup antipattern they replace, state-specific data, and transitions that consume the old state.


The Antipattern: Boolean and Option Soup

The naive way to track state is a pile of bools and Options, one flag per condition. It "works" but permits combinations that should be impossible, and every reader has to mentally reconstruct which combinations are valid:

// each field is independent — the compiler allows nonsense combinations
struct Connection {
    is_connected: bool,
    is_connecting: bool,
    session_id: Option<String>,   // only meaningful when connected
    retry_count: u32,             // only meaningful when connecting
}

Nothing stops is_connected and is_connecting both being true, or a session_id: Some(...) while is_connected is false. These invalid states are representable, so somewhere a bug will construct one, and every function touching Connection must defensively check flag combinations. This is the exact problem enums solve: make the illegal states impossible to write down.


One Enum, State-Specific Data

Model the states as enum variants, and attach to each variant only the data that state actually has. Now session_id exists only in the connected state, and retry_count only while connecting, the type system guarantees it:

enum Connection {
    Disconnected,
    Connecting { retry_count: u32 },
    Connected { session_id: String },
    Failed { error: String },
}

There is no way to have a session_id without being Connected, or a retry_count outside Connecting, the data lives inside the variant it belongs to. A match on Connection (Pattern Matching Deep Dive) is exhaustive: the compiler forces you to handle every state, and if you later add a Reconnecting variant, every match stops compiling until you address it. That's the payoff, the set of states is closed and enforced, not scattered across independent flags.

Gotcha: the moment you find yourself writing two booleans (or a bool plus an Option that's "only valid when the bool is set"), that's the signal to reach for an enum. n independent booleans encode 2^n combinations but usually only a handful are legal; an enum encodes exactly the legal ones. Flag soup doesn't announce itself, it accretes one field at a time, so the discipline is to notice the second correlated flag and convert before the invalid combinations multiply.


Transitions That Consume the Old State

A state machine's transitions should make the old state unusable, you shouldn't be able to keep using a Connecting value after it became Connected. Model transitions as methods that take self by value, consuming the old state and returning the new one. The compiler's move semantics then guarantee the stale state can't be touched:

impl Connection {
    fn connect(self, session_id: String) -> Connection {
        match self {
            Connection::Connecting { .. } => Connection::Connected { session_id },
            // transitions that don't apply return self unchanged (or an error)
            other => other,
        }
    }
}

let conn = Connection::Connecting { retry_count: 0 };
let conn = conn.connect("sess-123".into());   // old `conn` is moved — can't be reused

Because connect takes self, the previous binding is moved out and the borrow checker (Working With the Borrow Checker) rejects any later use of the old value, you're forced to use the returned new state. This is the runtime cousin of the typestate pattern: instead of encoding states in types checked at compile time, you encode them in variants and let move semantics enforce that a transition doesn't leave a usable stale state behind.


Handling "Impossible" Transitions

Not every transition is valid from every state, calling connect on an already-Failed connection is meaningless. You have three honest options, and the right one depends on whether the bad transition is a bug or an expected condition:

// Option A: return Result — the caller decides what a bad transition means
fn connect(self, id: String) -> Result<Connection, (Connection, TransitionError)> {
    match self {
        Connection::Connecting { .. } => Ok(Connection::Connected { session_id: id }),
        other => Err((other, TransitionError::NotConnecting)),  // hand back the state + why
    }
}
  • Return Result when a bad transition is a recoverable, expected condition, returning the original state alongside the error lets the caller retry or branch.
  • Ignore it (return self unchanged) when the transition is simply a no-op from that state.
  • Panic only when the transition is a genuine programming error that should never happen, the same rule as unwrap from Error Handling in Practice.

The key design point: returning the consumed state back inside the Err (as (Connection, TransitionError)) means a rejected transition doesn't destroy the state, the caller gets it back to try something else. Silently defaulting to some other state, by contrast, hides the bug.


When an Enum State Machine Fits

SituationReach for
A few well-defined runtime states, each with its own dataa data-carrying enum
Two+ correlated booleans / "valid only when" Optionsconvert to an enum
States fixed at compile time, transitions checked by the compilertypestate (markers)
A transition must invalidate the previous statemethod taking self by value → new state
Bad transition is expected/recoverablereturn Result (with the state in the Err)
Truly open-ended / plugin-defined statestrait objects, not an enum

The dividing line with typestate markers: use an enum when the state is data that changes at runtime and you need to match on it; use type-level markers when the state is known statically and you want invalid method calls to fail at compile time. Enums give runtime flexibility and exhaustive matching; markers give compile-time guarantees but can't represent a state read from I/O. Most application state machines want the enum.


Key Takeaways

  • Independent bool/Option fields let invalid state combinations be represented, so bugs can construct them; a data-carrying enum encodes exactly the legal states and makes the rest impossible to write down.
  • Attach state-specific data to the variant it belongs to (Connected { session_id }), so a field can't exist in a state where it's meaningless. A match is exhaustive, adding a state breaks every match until handled.
  • Model transitions as methods taking self by value and returning the new state; move semantics then make the stale state unusable, the runtime analogue of typestate.
  • Handle invalid transitions honestly: return Result (with the original state in the Err) for expected/recoverable cases, no-op for irrelevant ones, panic only for true programming errors. Don't silently default to a wrong state.
  • Choose an enum for runtime, data-carrying state you match on; choose type-level markers when states are static and you want invalid calls to fail at compile time.