State Machines in Rust With Enums and Typestate Explained
Model states as an enum when they change at run time and as types when the order is known at compile time. See both versions of a connection and which one to pick.

A document is a draft, then in review, then published. Code that checks if doc.status == "review" in forty places eventually checks it wrong in one. Rust gives two tools for making the states part of the type system. The enum version is the everyday one. The typestate version is the one that makes bugs impossible to compile. This post shows both for a connection and says when each fits. It is a design topic under organizing Rust projects.
Rust 1.90, edition 2024.
Enum states
#[derive(Debug)]
pub enum Conn {
Closed,
Open { session: u32 },
Failed { reason: String },
}
impl Conn {
pub fn open(self) -> Conn {
match self {
Conn::Closed => Conn::Open { session: 1 },
other => other,
}
}
pub fn close(self) -> Conn {
match self {
Conn::Open { .. } => Conn::Closed,
other => other,
}
}
}session exists only in Open, so no code can read a session from a closed connection. Transitions take self and return the new state. The match forces handling every variant, and adding Reconnecting breaks the build wherever a case is missing. An invalid transition here is a no-op; return Result if it should be an error.
Enums are what you store and load: a column named status maps to variants, and a collection of orders in different states is Vec<Order>.
Typestate
use std::marker::PhantomData;
pub struct Closed;
pub struct Open;
pub struct Socket<State> {
addr: String,
_state: PhantomData<State>,
}
impl Socket<Closed> {
pub fn new(addr: &str) -> Self {
Socket { addr: addr.to_string(), _state: PhantomData }
}
pub fn open(self) -> Socket<Open> {
Socket { addr: self.addr, _state: PhantomData }
}
}
impl Socket<Open> {
pub fn send(&self, data: &[u8]) -> usize { data.len() }
pub fn close(self) -> Socket<Closed> {
Socket { addr: self.addr, _state: PhantomData }
}
}Socket::new("x").send(b"hi") does not compile: send is not defined for Socket<Closed>. The error is E0599, no method found, which is the compiler enforcing your protocol. PhantomData takes no space, so Socket<Open> and Socket<Closed> have the same layout.
Choosing
| Situation | Use |
|---|---|
| State read from a database or message | enum |
| Many values in different states in one collection | enum |
| A sequence in code: connect, authenticate, query | typestate |
Builder where build needs fields set first | typestate |
| Need to serialize the state | enum |
The cost of typestate
Generic parameters spread through signatures, error messages get longer, and a function that returns "either state" needs an enum anyway. Use it where the bug it prevents is expensive: a protocol client that can send before authenticating, a payment flow that can capture before authorizing.
Where this stops
State machines with many transitions and guards become tables; a crate may help, but a match on (state, event) is often clearer. For the design context, see domain driven design in Rust, and for the builder application, public API design. The Rust Embedded Book describes the typestate pattern well.


