Many APIs have lifecycle rules: a connection must be opened before sending, a transaction must begin before committing, or a builder must receive required values before producing output. Runtime flags can enforce these rules, but Rust can sometimes encode them in types instead.
The typestate pattern represents each valid state with a distinct type and makes transitions consume one state to produce another.
Encode states as marker types
use std::marker::PhantomData;
struct Disconnected;
struct Connected;
struct Connection<State> {
endpoint: String,
_state: PhantomData<State>,
}
impl Connection<Disconnected> {
fn new(endpoint: String) -> Self {
Self { endpoint, _state: PhantomData }
}
fn connect(self) -> Connection<Connected> {
Connection {
endpoint: self.endpoint,
_state: PhantomData,
}
}
}
impl Connection<Connected> {
fn send(&self, payload: &[u8]) {
println!("sending {} bytes", payload.len());
}
}send does not exist for Connection<Disconnected>. Incorrect call ordering becomes a compile-time error instead of a branch in production.
Consume values during transitions
Taking self by value prevents callers from retaining the old logical state after a successful transition. This works especially well when the transition changes ownership or establishes an invariant.
Real connections can fail, so a practical transition usually returns Result<Connection<Connected>, Error>. On failure, decide whether callers need the original value back; that requirement affects the error type and API shape.
Keep shared behavior generic
Methods valid in every state can live on a generic implementation:
impl<State> Connection<State> {
fn endpoint(&self) -> &str {
&self.endpoint
}
}State-specific implementations should contain only operations whose validity depends on the state.
Trade-offs
Typestate shifts checks from runtime to compile time, but it introduces generic parameters and more types. That can complicate collections containing mixed states, dynamic workflows, serialization, and public APIs where callers need to erase state.
Use it when the lifecycle is small, stable, and important. A state machine driven by external events may be clearer as an enum with explicit runtime transitions.
Common pitfalls
Encoding every boolean as a type
Typestate is valuable for meaningful invariants, not ordinary application flags. Too many marker types make APIs difficult to navigate.
Hiding fallible transitions
Compile-time state does not make network or I/O operations infallible. Preserve Result where the underlying operation can fail.
Making state types part of unnecessary public surface
Marker types can often remain implementation details or be documented as part of a focused API. Avoid exposing generic complexity without a usability benefit.
Fighting dynamic requirements
If state is discovered only at runtime and callers routinely need to branch over it, an enum may express the domain more directly.
Use types for durable invariants
Typestate works best when a small set of operations has a strict ordering that should never be violated. By making invalid operations unavailable, Rust can move an entire class of lifecycle mistakes from tests and runtime checks into compilation.