Introduction
The type-state pattern uses marker types and PhantomData to encode state machines in the type system. Invalid state transitions become compile-time errors, eliminating entire categories of runtime bugs.
Key Concepts
- Type-State: Encoding an object's state as a type parameter, so the compiler enforces valid transitions.
- State Marker: An empty struct representing a state (e.g.,
struct Open;,struct Closed;). - Consuming Transition: Methods that take
selfby value and return a new type with a different state parameter.
Real World Context
HTTP client builders use type-state to ensure required fields are set before building. Database transaction types use it to prevent operations after commit/rollback. Protocol implementations use it to enforce valid message ordering.
Deep Dive
Basic State Machine
rustuse std::marker::PhantomData; struct Open; struct Closed; struct Locked; struct Door<State> { _state: PhantomData<State>, } impl Door<Closed> { fn new() -> Self { Door { _state: PhantomData } } fn open(self) -> Door<Open> { Door { _state: PhantomData } } fn lock(self) -> Door<Locked> { Door { _state: PhantomData } } } impl Door<Open> { fn close(self) -> Door<Closed> { Door { _state: PhantomData } } // Cannot lock an open door — method not implemented! } impl Door<Locked> { fn unlock(self) -> Door<Closed> { Door { _state: PhantomData } } }
Invalid transitions are compile-time errors:
rustlet door = Door::<Closed>::new(); let door = door.open(); // Door<Open> let door = door.close(); // Door<Closed> let door = door.lock(); // Door<Locked> // door.open(); // Error! Door<Locked> has no .open() method
Builder Pattern with Type-State
ruststruct NoUrl; struct HasUrl; struct RequestBuilder<U> { url: Option<String>, headers: Vec<(String, String)>, _state: PhantomData<U>, } impl RequestBuilder<NoUrl> { fn new() -> Self { RequestBuilder { url: None, headers: vec![], _state: PhantomData } } fn url(self, url: &str) -> RequestBuilder<HasUrl> { RequestBuilder { url: Some(url.to_string()), headers: self.headers, _state: PhantomData } } } impl RequestBuilder<HasUrl> { fn send(self) -> Result<(), String> { // Only callable after url() has been set Ok(()) } } // RequestBuilder::new().send(); // Error! send() only exists on HasUrl RequestBuilder::new().url("https://example.com").send().unwrap(); // ✓
Common Pitfalls
- Too many states — A state machine with 10+ states creates a combinatorial explosion of impl blocks. Keep state machines small or use enums for complex cases.
- Forgetting that transitions consume self — Each transition takes
selfby value, invalidating the previous state. This is intentional but surprising to newcomers.
Best Practices
- Use type-state for critical invariants — Apply it where invalid state transitions would cause security issues or data corruption.
- Combine with the builder pattern — Type-state builders ensure required fields are set before calling
.build()or.send().
Summary
- The type-state pattern encodes states as type parameters using marker types and
PhantomData. - Invalid transitions become compile-time errors.
- Methods consume
selfand return a new type with the next state. - Ideal for builders, protocol state machines, and security-critical APIs.
Code Examples
rust
use std::marker::PhantomData;
// Type-state builder: ensures url is set before send
struct NoUrl;
struct HasUrl;
struct Request<S> {
url: Option<String>,
_state: PhantomData<S>,
}
impl Request<NoUrl> {
fn new() -> Self {
Request { url: None, _state: PhantomData }
}
fn url(self, url: &str) -> Request<HasUrl> {
Request { url: Some(url.to_string()), _state: PhantomData }
}
}
impl Request<HasUrl> {
fn send(self) { println!("Sending to {}", self.url.unwrap()); }
}
// Request::new().send(); // Compile error!
Request::new().url("https://example.com").send(); // OK