| |

Rust 113 🦀 Type-Level State Machine Pattern (Typestate Pattern)

⏱️ Estimated read: 13 min | |

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

ComponentRoleExample
State markersZero-sized typesstruct Red;
PhantomDataCarries the state parameter_state: PhantomData<State>
State impl blockMethods for one stateimpl Light<Red> { ... }
TransitionConsumes self, returns nextfn go(self) -> Light<Green>

Typestate vs Enum

AspectTypestateEnum
Invalid transitionCompile errorRuntime error
Runtime costZeroBranch per check
Dynamic stateNot directlyYes
Collections of mixed statesNot directlyYes
SerializationNeeds enum boundaryDirect
Refactoring costHighLow

When to Use

SituationPattern
API boundary, construction flowTypestate
Protocol handshakeTypestate
Resource lifecycleTypestate
Event loop, parser, UIEnum
Mixed states in a collectionEnum
State loaded from disk/networkEnum

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

PitfallWhy It HappensFix
Unused type parameter errorNo PhantomData fieldAdd _state: PhantomData<State>
Cannot store in a collectionMixed states are different typesUse an enum wrapper
Cannot serializeType parameter is not runtime dataSerialize the enum, deserialize to typestate
Fallible transition loses old stateself consumed on the callReturn the old state with the error
Refactoring is expensiveState graph is rigidAccept 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

ItemValue
PatternState encoded in the type parameter
MarkerZero-sized unit struct
State fieldPhantomData<State>
Transitionfn next(self) -> NextState
Invalid transitionCompile error
Runtime costZero
BoundaryEnum for serialization and collections
Testingtrybuild compile-fail tests

Key takeaways:

  • The state is a type, not a field. Light<Red> and Light<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 .
  • PhantomData carries the state parameter. Without it, the state parameter would be unused and the struct would not compile. The PhantomData field is zero-sized .
  • The pattern is zero-cost. The state markers and the PhantomData field have no runtime representation. The size_of the 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.


Was this helpful?


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!