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
boolplus anOptionthat's "only valid when the bool is set"), that's the signal to reach for an enum.nindependent booleans encode2^ncombinations 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
Resultwhen a bad transition is a recoverable, expected condition, returning the original state alongside the error lets the caller retry or branch. - Ignore it (return
selfunchanged) 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
unwrapfrom 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
| Situation | Reach for |
|---|---|
| A few well-defined runtime states, each with its own data | a data-carrying enum |
Two+ correlated booleans / "valid only when" Options | convert to an enum |
| States fixed at compile time, transitions checked by the compiler | typestate (markers) |
| A transition must invalidate the previous state | method taking self by value → new state |
| Bad transition is expected/recoverable | return Result (with the state in the Err) |
| Truly open-ended / plugin-defined states | trait 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/Optionfields 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. Amatchis exhaustive, adding a state breaks every match until handled. - Model transitions as methods taking
selfby 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 theErr) 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
matchon; choose type-level markers when states are static and you want invalid calls to fail at compile time.