Rust 113 🦀 Type-Level State Machine Pattern (Typestate Pattern)
The typestate pattern moves a state machine from the runtime to the type system. Instead of a single Order type with a state: OrderState field, the pattern produces Order<Created>, Order<Paid>, and Order<Shipped> as distinct types, each with only the methods that are legal in that state. Calling a method that does not exist on the current type is a compile error, not a runtime Result::Err . The pattern is built on PhantomData, consumes self on transitions, and costs nothing at runtime because the state markers are zero-sized.
This chapter covers the typestate pattern as it is implemented in Rust. It begins with the marker types and the PhantomData field that carries the state parameter without occupying memory, then explains the consuming-self transition that makes the old state unusable after the move. It addresses the trade-offs—the serialization boundary, the enum wrapper for dynamic contexts, and the refactoring cost of a rigid state graph—and the cases where the pattern is the wrong choice.
Key point: The typestate pattern does not add a runtime check. It removes the possibility of the check failing. A Connection<Authenticated> cannot be confused with a Connection<Connected> because they are different types, and the send() method exists only on the authenticated one.
Why typestate exists
The runtime state check problem. A conventional state machine stores the state in a field and checks it before every operation. The check is a branch, and the branch can fail. The failure is a runtime error, and the error is a bug that the compiler did not catch. The typestate pattern moves the check to the type system: the operation exists only on the type that represents the correct state, so the call either compiles (and is correct) or does not compile (and is caught before the binary is produced) .
The builder pattern connection. The typestate pattern is already familiar to Rust programmers through the builder pattern. A FooBuilder is a state that cannot produce a Foo until the required fields are set. The into_foo method consumes the builder and returns the Foo, and the builder’s type is different from the Foo‘s type. The builder is the simplest example of a typestate machine, and the traffic light or the TCP connection is the same pattern with more states .
The ownership requirement. The pattern depends on Rust’s move semantics. A transition method takes self, not &mut self, and returns the next state. The old value is consumed by the move, and the compiler prevents any further use of it. This is what makes the old state unreachable: it no longer exists after the transition . Languages without affine ownership cannot enforce this property at the type level.
The zero-cost property. The state markers are unit structs with no fields, and the PhantomData<State> field is zero-sized. The Light<Red> and Light<Green> types have the same size and layout; the difference exists only at compile time. The pattern adds no runtime overhead, no vtable, and no allocation .
a. The marker types and the PhantomData field
The pattern has three components: the state marker types, the struct that carries the state parameter, and the impl blocks that are specific to each state. The marker types are empty structs. They exist only to be used as type parameters, and they have no runtime representation.
use std::marker::PhantomData;
// State markers: zero-sized, type-level only.
pub struct Red;
pub struct Green;
pub struct Yellow;
// The machine carries the state in its type parameter.
// PhantomData<State> is zero bytes at runtime.
pub struct Light<State> {
_state: PhantomData<State>,
}
The PhantomData<State> field is what makes the state parameter “used” from the compiler’s perspective. Without it, the State parameter would be unused, and the struct would not compile. The PhantomData is the mechanism that tells the compiler “this type is parameterized by State, even though no State value is stored” .
The impl blocks are where the state-specific methods live. Each block is bound to a specific marker type, and the methods it defines are available only on that type.
impl Light<Red> {
// Only way to create a light: it must start Red.
pub fn new() -> Self {
Light { _state: PhantomData }
}
// Red → Green. Consumes self: the Red light is gone.
pub fn go(self) -> Light<Green> {
Light { _state: PhantomData }
}
}
impl Light<Green> {
// Green → Yellow. No `go()` on Green: the transition is one-way.
pub fn slow(self) -> Light<Yellow> {
Light { _state: PhantomData }
}
}
The compiler enforces the transitions. Light<Red> has go() but not slow(). Light<Green> has slow() but not go(). Light<Yellow> has stop() but not go() or slow(). A call to a non-existent method is a compile error with a clear message: “no method named slow found for struct Light<Red>” .
b. The consuming transition and the ownership discipline
The transition method takes self by value, not &mut self. This is the detail that makes the pattern work. When red.go() is called, the red value is moved into the method, and the method returns a new Light<Green>. The red variable cannot be used again because it has been moved. The old state is gone, and the new state is the only handle to the machine .
let red = Light::<Red>::new();
let green = red.go();
let yellow = green.slow();
let red2 = yellow.stop();
// The compiler prevents this:
// red.go(); // error: use of moved value
// green.slow(); // error: use of moved value
// red.slow(); // error: no method `slow` on `Light<Red>`
The ownership discipline has a consequence for error handling. A fallible transition—opening a file, connecting to a server—cannot simply return the next state. The self is consumed by the call, so the caller loses the old state whether the transition succeeds or fails. The common solutions are to return a Result<NextState, (Error, OldState)> that gives the old state back on failure, or to use a runtime enum at the boundary where the state is not known statically .
The consuming transition also means the state cannot change in place through a mutable reference. A Connection<Disconnected> cannot become a Connection<Connected> through &mut self, because the concrete type of a value cannot change. The transition must move the value and return a new type. This is the reason the pattern is described as “ownership transfer” rather than “state mutation” .
c. The trade-offs and the boundary
The typestate pattern is not universally applicable. It is strongest at API boundaries, construction flows, and resource lifecycles where the sequence is known at compile time. It is weakest in event loops, protocol parsers, and UI workflows where the state is genuinely dynamic .
The serialization boundary. A Connection<Authenticated> cannot be serialized directly, because the type parameter is not runtime data. The standard solution is to serialize to an enum representation and deserialize through it, converting the enum back to the typestate at the boundary. The core logic uses typestate; the persistence layer uses the enum .
The collection boundary. A Vec<Connection<State>> cannot hold connections in different states. The enum representation is the only way to store mixed states in a collection, and the typestate is recovered by matching on the enum and converting to the specific type .
The refactoring cost. Adding a state or changing the order of transitions requires refactoring the surrounding states, because their interfaces must remain compatible with the new state. The pattern makes the state graph rigid, and the rigidity is the source of its safety. The enum-based state machine is more flexible, but the flexibility is the source of its runtime errors .
The compile-fail test. The pattern’s promise is that invalid flows do not compile. The trybuild crate is the tool for testing this promise: it compiles code that is expected to fail and verifies the error message. A typestate library should have a compile-fail test for every invalid transition it claims to prevent .
Complete Example Session
// ============================================
// PART 1: THE STATE MARKERS
// ============================================
// Zero-sized types, type-level only.
use std::marker::PhantomData;
pub struct Disconnected;
pub struct Connected;
pub struct Authenticated;
// ============================================
// PART 2: THE MACHINE
// ============================================
// The state parameter is carried by PhantomData.
pub struct Connection<State> {
_state: PhantomData<State>,
// In a real implementation, the socket and session
// would be inside the state markers themselves.
}
// ============================================
// PART 3: THE DISCONNECTED STATE
// ============================================
// The only way to create a connection: it starts disconnected.
impl Connection<Disconnected> {
pub fn new() -> Self {
Connection { _state: PhantomData }
}
// Connect consumes self and returns Connected.
pub fn connect(self, _addr: &str) -> Result<Connection<Connected>, String> {
// Simulated connection logic.
Ok(Connection { _state: PhantomData })
}
}
// ============================================
// PART 4: THE CONNECTED STATE
// ============================================
// Authentication is the only transition from Connected.
impl Connection<Connected> {
pub fn authenticate(self, _password: &str) -> Result<Connection<Authenticated>, String> {
Ok(Connection { _state: PhantomData })
}
}
// ============================================
// PART 5: THE AUTHENTICATED STATE
// ============================================
// Sending data is available only when authenticated.
impl Connection<Authenticated> {
pub fn send(&self, _data: &[u8]) -> Result<(), String> {
Ok(())
}
}
// ============================================
// PART 6: THE CORRECT USAGE
// ============================================
// The sequence compiles.
fn correct_flow() -> Result<(), String> {
let conn = Connection::new();
let conn = conn.connect("server:8080")?;
let conn = conn.authenticate("secret")?;
conn.send(b"data")?;
Ok(())
}
// ============================================
// PART 7: THE INVALID USAGE
// ============================================
// The sequence does not compile.
// fn invalid_flow() {
// let conn = Connection::new();
// let conn = conn.connect("server:8080").unwrap();
// conn.send(b"data").unwrap(); // ERROR: send() not on Connected
// }
// ============================================
// PART 8: THE ENUM BOUNDARY
// ============================================
// For dynamic contexts, wrap the typestate in an enum.
enum AnyConnection {
Disconnected(Connection<Disconnected>),
Connected(Connection<Connected>),
Authenticated(Connection<Authenticated>),
}
// The enum is the runtime representation.
// The typestate is the compile-time representation.
// ============================================
// PART 9: THE ZERO-COST CHECK
// ============================================
// PhantomData is zero bytes.
fn size_check() {
assert_eq!(std::mem::size_of::<Connection<Disconnected>>(), 0);
assert_eq!(std::mem::size_of::<Connection<Connected>>(), 0);
assert_eq!(std::mem::size_of::<Connection<Authenticated>>(), 0);
}
// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// The state is in the type.
// The transition consumes the old state.
// The compiler enforces the sequence.
fn main() {
correct_flow().unwrap();
size_check();
}
The ten parts build a connection state machine with the typestate pattern: the markers, the machine, the state-specific impl blocks, the valid flow, the invalid flow (commented out because it does not compile), the enum boundary, and the zero-size check. The pattern is consistent: the state is a type parameter, and the transition is a method that consumes the old state.
Quick Reference
Pattern Components
| Component | Role | Example |
|---|---|---|
| State markers | Zero-sized types | struct Red; |
PhantomData | Carries the state parameter | _state: PhantomData<State> |
State impl block | Methods for one state | impl Light<Red> { ... } |
| Transition | Consumes self, returns next | fn go(self) -> Light<Green> |
Typestate vs Enum
| Aspect | Typestate | Enum |
|---|---|---|
| Invalid transition | Compile error | Runtime error |
| Runtime cost | Zero | Branch per check |
| Dynamic state | Not directly | Yes |
| Collections of mixed states | Not directly | Yes |
| Serialization | Needs enum boundary | Direct |
| Refactoring cost | High | Low |
When to Use
| Situation | Pattern |
|---|---|
| API boundary, construction flow | Typestate |
| Protocol handshake | Typestate |
| Resource lifecycle | Typestate |
| Event loop, parser, UI | Enum |
| Mixed states in a collection | Enum |
| State loaded from disk/network | Enum |
Best Practices
✅ Do This:
// Use zero-sized markers for states.
pub struct Connected; // ✅
// Consume self on transitions.
fn connect(self) -> Result<Connection<Connected>, Error> // ✅
// Use PhantomData to carry the state.
_state: PhantomData<State> // ✅
// Use an enum at the serialization boundary.
enum AnyConnection { ... } // ✅
❌ Don’t Do This:
// Don't use &mut self for the transition.
fn connect(&mut self) // the type cannot change in place // ❌
// Don't store the state as a runtime field.
struct Connection { state: State } // defeats the purpose // ❌
// Don't use typestate for dynamic state.
// The compile-time graph must be known. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Unused type parameter error | No PhantomData field | Add _state: PhantomData<State> |
| Cannot store in a collection | Mixed states are different types | Use an enum wrapper |
| Cannot serialize | Type parameter is not runtime data | Serialize the enum, deserialize to typestate |
| Fallible transition loses old state | self consumed on the call | Return the old state with the error |
| Refactoring is expensive | State graph is rigid | Accept the cost, or use enum for volatile graphs |
Real-World Examples
1. Traffic Light
Light<Red>::new().go().slow().stop()
2. TCP Connection
Connection<Disconnected>::new().connect(addr)?.authenticate(pw)?.send(data)?
3. Builder
RequestBuilder::new().url("...").timeout(d).build()
4. File Handle
FileHandle<Closed>::open("file")?.read_line(0)
5. GPU Buffer
GPUBuffer<Empty>::new().write_block(data)?.enqueue_kernel(k)
6. PhantomData
_state: PhantomData<State>
7. Size Check
assert_eq!(size_of::<Light<Red>>(), 0);
8. Enum Boundary
enum AnyState { Red(Light<Red>), Green(Light<Green>) }
9. Fallible Transition
fn connect(self) -> Result<Connection<Connected>, (Error, Self)>
10. Compile-Fail Test
// trybuild test: `red.slow()` must fail to compile
Visual
The State Graph
┌─────────────────────────────────────────────────────────┐
│ Light<Red> Light<Green> Light<Yellow> │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ go() │─────►│ slow() │─────►│ stop() │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ ▲ │ │
│ └───────────────────────────────────────┘ │
│ │
│ Each type has only its own transition method. │
│ The transition consumes self and returns the next type.│
└─────────────────────────────────────────────────────────┘
The Consuming Transition
┌─────────────────────────────────────────────────────────┐
│ let red = Light::<Red>::new(); │
│ │ │
│ ▼ red.go() │
│ the `red` value is MOVED into go() │
│ │ │
│ ▼ │
│ returns Light<Green> │
│ │ │
│ ▼ │
│ `red` is no longer valid (moved) │
│ Only the new `green` value exists. │
└─────────────────────────────────────────────────────────┘
Typestate vs Enum
┌─────────────────────────────────────────────────────────┐
│ TYPESTATE │
│ Light<Red> and Light<Green> are different types. │
│ Invalid transition = compile error. │
│ Zero runtime cost. │
│ │
│ ENUM │
│ Light { state: State::Red | State::Green } │
│ Invalid transition = runtime check. │
│ Branch per operation. │
└─────────────────────────────────────────────────────────┘
The Boundary
┌─────────────────────────────────────────────────────────┐
│ COMPILE-TIME (typestate) │
│ Connection<Disconnected> │
│ Connection<Connected> │
│ Connection<Authenticated> │
│ │ │
│ ▼ serialization boundary │
│ RUNTIME (enum) │
│ AnyConnection { Disconnected(..), Connected(..) } │
│ │ │
│ ▼ deserialization │
│ Convert enum back to typestate. │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Pattern | State encoded in the type parameter |
| Marker | Zero-sized unit struct |
| State field | PhantomData<State> |
| Transition | fn next(self) -> NextState |
| Invalid transition | Compile error |
| Runtime cost | Zero |
| Boundary | Enum for serialization and collections |
| Testing | trybuild compile-fail tests |
Key takeaways:
- The state is a type, not a field.
Light<Red>andLight<Green>are distinct types, and the transition methods exist only on the type that represents the correct state . - The transition consumes
self. The old state is moved into the method and cannot be used afterward. This is what makes the old state unreachable and the sequence enforced . PhantomDatacarries the state parameter. Without it, the state parameter would be unused and the struct would not compile. ThePhantomDatafield is zero-sized .- The pattern is zero-cost. The state markers and the
PhantomDatafield have no runtime representation. Thesize_ofthe typestate machine equals the size of its data fields . - The enum is the boundary. Serialization, collections, and dynamic contexts require a runtime representation. The enum is the representation, and the typestate is recovered at the boundary .
- The pattern is strongest at API boundaries. Construction flows, protocol handshakes, and resource lifecycles benefit from the compile-time enforcement. Event loops and parsers do not .
Remember: The typestate pattern is the type system doing the work that a runtime check would otherwise do. The check is not faster; it is absent, because the invalid call cannot be written. The cost is the rigidity of the state graph, the boilerplate of the impl blocks, and the enum wrapper at the serialization boundary. The benefit is that a whole class of bugs—calling the wrong method in the wrong state—is moved from runtime to compile time, where the compiler catches them before the binary exists.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!