The state machine that compiles
You're writing a game loop. The player can be idle, moving, or attacking. In Python, you track this with a string variable. You check if state == "idle". You refactor later, rename "idle" to "waiting", but miss one check. The game crashes three hours into a playtest because the player is stuck in a state that no longer exists. Rust enums kill this class of bug at compile time. The compiler knows every valid state. If you add a state, the compiler screams until you handle it. If you try to transition to a state that doesn't exist, the code won't build.
Enums as sealed state sets
An enum in Rust is a sum type. It defines a closed set of possibilities. A value of type GameState must be exactly one of the variants you declared. No more, no less. The match expression is the enforcement mechanism. It requires you to handle every variant. This creates a feedback loop: the type system guarantees the state is valid, and the match expression guarantees the logic covers every state. You can't have a "null" state. You can't have a typo. The state space is sealed.
Minimal example: consuming transitions
Start with a simple cycle. A traffic light rotates through red, green, and yellow. The enum lists the states. The next method defines the transitions.
#[derive(Debug)]
enum TrafficLight {
Red,
Yellow,
Green,
}
impl TrafficLight {
/// Advance the light to the next state in the cycle.
fn next(self) -> TrafficLight {
// Take self by value to consume the old state.
// This prevents reusing the previous state after transition.
match self {
TrafficLight::Red => TrafficLight::Green,
TrafficLight::Green => TrafficLight::Yellow,
TrafficLight::Yellow => TrafficLight::Red,
}
}
}
fn main() {
let mut light = TrafficLight::Red;
// Call next, which moves light into the function.
// The old value is gone. We get back a new state.
light = light.next();
println!("{light:?}");
}
Why value semantics matter
The next method takes self by value, not &self. This is intentional. When you call light.next(), the light variable is moved into the function. The old value is consumed. The function returns a new TrafficLight. This design prevents a subtle bug: you can't accidentally keep using the old state after the transition. If you try to read light after calling next without reassigning, the compiler rejects you with E0382 (use of moved value). The state machine enforces its own lifecycle.
Convention aside: the community prefers taking self by value for state transitions. It signals that the transition is destructive. The old state is dead. The new state is born. If you take &self, you imply the state can be observed without changing, which is fine for getters but dangerous for transitions. Stick to self for methods that change the state.
Trust the move semantics. They protect the state.
Realistic example: data in states
Real state machines carry data. A connection isn't just "connected"; it holds a socket handle. An error holds a message. The enum variants hold this data. When you transition, you extract what you need and build the new state.
#[derive(Debug)]
enum Connection {
Disconnected,
// Hold the target host while connecting.
Connecting { host: String },
// Hold the socket handle only when connected.
Connected { socket: u32 },
// Preserve error details for debugging.
Error { message: String },
}
impl Connection {
/// Attempt to start a connection to the specified host.
fn connect(self, host: &str) -> Connection {
match self {
// Only allow connection from Disconnected state.
Connection::Disconnected => Connection::Connecting {
host: host.to_string(),
},
// Reject transitions from active states.
// The .. pattern discards data we don't need for the error.
Connection::Connecting { .. } => Connection::Error {
message: "Already connecting".to_string(),
},
Connection::Connected { .. } => Connection::Error {
message: "Already connected".to_string(),
},
Connection::Error { .. } => Connection::Error {
message: "Cannot connect from error state".to_string(),
},
}
}
}
The .. pattern in the match arms discards data you don't need. This is safe. You can't accidentally use a socket from a Disconnected state because the variant doesn't hold one. The compiler forces you to acknowledge the data layout of every state. If you try to access socket in the Disconnected arm, the code won't compile. Data follows the state. If the variant doesn't hold it, you can't use it.
Handling failures and guards
Transitions often fail. A connection attempt might time out. A payment might be declined. Returning Result<Self, Error> models this cleanly. The transition returns the new state on success, or an error on failure. The caller must handle both cases.
#[derive(Debug)]
enum ConnectError {
AlreadyConnecting,
NetworkTimeout,
}
impl Connection {
/// Try to connect, returning an error if the transition is invalid.
fn try_connect(self, host: &str) -> Result<Connection, ConnectError> {
match self {
Connection::Disconnected => {
// Simulate success for the example.
// In real code, this might perform I/O.
Ok(Connection::Connecting {
host: host.to_string(),
})
}
// Reject invalid transitions with specific errors.
Connection::Connecting { .. } => Err(ConnectError::AlreadyConnecting),
Connection::Connected { .. } => Err(ConnectError::AlreadyConnecting),
Connection::Error { .. } => Err(ConnectError::AlreadyConnecting),
}
}
}
This pattern scales. You can add guard conditions inside the match arms. Check data before allowing a transition. If the guard fails, return an error. The compiler still forces you to handle every variant. The error type captures why the transition was rejected. This is more expressive than panicking or returning Option.
Pitfalls and compiler feedback
Adding a new state breaks compilation. You add Paused to GameState. Every match on GameState now fails with E0004 (non-exhaustive patterns). This is the superpower. The compiler highlights every place you need to update logic. You can't forget to handle Paused in the rendering loop. The breakage is localized and actionable.
Embrace the breakage. It's the compiler doing your QA.
Avoid mutable data inside variants. If you put RefCell inside a variant, you re-introduce runtime checks and potential panics. Keep state transitions pure. Consume the old state, produce the new one. If you need mutation, mutate the data before the transition, or return the new state with the updated data. Mutable state inside enums defeats the purpose of the type system. You lose the guarantee that the state is consistent.
Convention aside: if you publish a library with a public state enum, consider adding #[non_exhaustive] to the enum definition. This tells downstream users that you might add more variants in a future patch release. Their code will compile against your current version, but they won't be able to match exhaustively. They'll have to use a wildcard arm. This protects you from breaking changes when you extend the state machine. Use this for library APIs. Use exhaustive enums for internal code where you control all the match sites.
Decision: enums vs flags vs crates
Use enums for state machines when the states are mutually exclusive and transitions follow strict rules. The compiler guarantees you handle every case. Use a struct with boolean flags when properties are independent. A window can be both "maximized" and "focused". These aren't states; they are attributes. Enums force you to model "MaximizedAndFocused", "MaximizedNotFocused", etc., which explodes combinatorially. Use a dedicated state machine crate when the graph has hundreds of states and you need serialization, visualization, or complex guard conditions. Enums are lightweight and built-in. Crates add overhead for scale.
Start with enums. They're free, fast, and correct by construction.