130: Typestate Pattern
Difficulty: โญโญโญ Level: Advanced Make invalid state transitions impossible by encoding state in the type โ a `Door<Locked>` has no `open()` method because it doesn't exist.The Problem This Solves
Consider a database connection. You need to connect, then authenticate, then query. What stops someone from calling `query()` on a connection that hasn't authenticated yet? Without typestate: runtime checks and panics, or `Result` errors everywhere, or trusting documentation. The type `Connection` is the same whether you've authenticated or not โ the compiler can't help you. The same problem shows up constantly: a file that's readable before it's opened, a network socket that's writable before it's bound, an HTTP request that can be sent before a URL is set. In each case, some methods are only valid in some states, but the type doesn't say which. With typestate, each state is a different type. `Connection<Disconnected>` and `Connection<Authenticated>` are different types. The `query()` method only exists on `Connection<Authenticated>`. You cannot call it on `Connection<Disconnected>` โ the method simply doesn't exist there. The compiler enforces the protocol at every call site. This pattern is used in `tokio` (channel Sender/Receiver states) and `sqlx` (prepared statements).The Intuition
The trick is simple: make the state a phantom type parameter. `struct Door<State>` where `State` is a zero-sized marker struct (`struct Open;`, `struct Closed;`, `struct Locked;`). These markers store nothing โ they're pure type-level labels. Then write separate `impl` blocks for each state. `impl Door<Open>` gets the `walk_through()` and `close()` methods. `impl Door<Closed>` gets `open()` and `lock()`. `impl Door<Locked>` gets only `unlock()`. Each transition method consumes the old door (takes `self` by value) and returns a door with a new state type. The old door is gone โ you can't accidentally use a locked door as if it were open because the variable has been moved.How It Works in Rust
use std::marker::PhantomData;
// State markers โ zero-sized, no data stored
struct Open;
struct Closed;
struct Locked;
// Door parameterized by its current state
struct Door<State> {
material: String,
_state: PhantomData<State>, // PhantomData: use State in type without storing it
}
// Methods available only when the door is Open
impl Door<Open> {
fn new(material: &str) -> Self {
Door { material: material.to_string(), _state: PhantomData }
}
fn walk_through(&self) { println!("Walking through {} door", self.material); }
fn close(self) -> Door<Closed> { // self is consumed โ Door<Open> is gone
Door { material: self.material, _state: PhantomData }
}
}
// Methods available only when the door is Closed
impl Door<Closed> {
fn open(self) -> Door<Open> {
Door { material: self.material, _state: PhantomData }
}
fn lock(self) -> Door<Locked> {
Door { material: self.material, _state: PhantomData }
}
}
// Methods available only when the door is Locked
impl Door<Locked> {
fn unlock(self) -> Door<Closed> {
Door { material: self.material, _state: PhantomData }
}
// No open() here โ you can't open a locked door directly
}
Valid usage:
let door = Door::<Open>::new("oak");
door.walk_through(); // โ Open has walk_through
let door = door.close(); // door is now Door<Closed>
let door = door.lock(); // door is now Door<Locked>
// door.open(); // compile error: no method `open` on Door<Locked>
let door = door.unlock(); // Door<Closed>
let door = door.open(); // Door<Open>
door.walk_through(); // โ
Connection protocol example:
struct Connection<S> { host: String, _state: PhantomData<S> }
impl Connection<Disconnected> {
fn connect(self) -> Connection<Connected> { /* ... */ }
}
impl Connection<Connected> {
fn authenticate(self, password: &str) -> Connection<Authenticated> { /* ... */ }
}
impl Connection<Authenticated> {
fn query(&self, q: &str) -> String { /* ... */ } // only here!
}
What This Unlocks
- Protocol enforcement โ TCP connections, TLS handshakes, OAuth flows: each step is a type transition; skipping a step is a compile error.
- Resource lifecycle โ files that must be opened before reading, released before re-acquiring; the type tracks the lifecycle without runtime flags.
- Builder APIs โ each `with_*` method transitions the builder type, and `build()` only exists when all required fields have been set (see example 131).
Key Differences
| Concept | OCaml | Rust | |
|---|---|---|---|
| State encoding | GADT: `type _ door = OpenDoor : open_state door \ | ClosedDoor : closed_state door` | Phantom type param: `struct Door<State>` with marker structs |
| Method availability | Pattern-match phantom type in function signature | Separate `impl Door<Open>`, `impl Door<Closed>` blocks | |
| Value consumption | Type annotation, value not consumed | `self` taken by value โ old state is moved away, unusable | |
| Zero runtime cost | Depends on OCaml's unboxing | `PhantomData` is zero-sized โ no memory overhead |
// Example 130: Typestate Pattern โ State Machines in Types
use std::marker::PhantomData;
// Approach 1: Zero-sized state markers
struct Open;
struct Closed;
struct Locked;
struct Door<State> {
material: String,
_state: PhantomData<State>,
}
impl Door<Open> {
fn new(material: &str) -> Self {
Door { material: material.to_string(), _state: PhantomData }
}
fn close(self) -> Door<Closed> {
println!("Closing {} door", self.material);
Door { material: self.material, _state: PhantomData }
}
fn walk_through(&self) {
println!("Walking through {} door", self.material);
}
}
impl Door<Closed> {
fn open(self) -> Door<Open> {
println!("Opening {} door", self.material);
Door { material: self.material, _state: PhantomData }
}
fn lock(self) -> Door<Locked> {
println!("Locking {} door", self.material);
Door { material: self.material, _state: PhantomData }
}
}
impl Door<Locked> {
fn unlock(self) -> Door<Closed> {
println!("Unlocking {} door", self.material);
Door { material: self.material, _state: PhantomData }
}
}
// Approach 2: Enum-based (runtime) for comparison
#[derive(Debug, PartialEq)]
enum DoorState { Open, Closed, Locked }
struct RuntimeDoor {
state: DoorState,
material: String,
}
impl RuntimeDoor {
fn new(material: &str) -> Self {
RuntimeDoor { state: DoorState::Open, material: material.to_string() }
}
fn close(&mut self) -> Result<(), &str> {
match self.state {
DoorState::Open => { self.state = DoorState::Closed; Ok(()) }
_ => Err("Can only close an open door"),
}
}
fn open(&mut self) -> Result<(), &str> {
match self.state {
DoorState::Closed => { self.state = DoorState::Open; Ok(()) }
_ => Err("Can only open a closed door"),
}
}
}
// Approach 3: Connection protocol typestate
struct Disconnected;
struct Connected;
struct Authenticated;
struct Connection<S> {
host: String,
_state: PhantomData<S>,
}
impl Connection<Disconnected> {
fn new(host: &str) -> Self {
Connection { host: host.to_string(), _state: PhantomData }
}
fn connect(self) -> Connection<Connected> {
Connection { host: self.host, _state: PhantomData }
}
}
impl Connection<Connected> {
fn authenticate(self, _password: &str) -> Connection<Authenticated> {
Connection { host: self.host, _state: PhantomData }
}
fn disconnect(self) -> Connection<Disconnected> {
Connection { host: self.host, _state: PhantomData }
}
}
impl Connection<Authenticated> {
fn query(&self, q: &str) -> String {
format!("Query '{}' on {}", q, self.host)
}
fn disconnect(self) -> Connection<Disconnected> {
Connection { host: self.host, _state: PhantomData }
}
}
fn main() {
// Typestate door
let door = Door::<Open>::new("oak");
door.walk_through();
let door = door.close();
let door = door.lock();
let door = door.unlock();
let door = door.open();
door.walk_through();
// Connection protocol
let conn = Connection::<Disconnected>::new("db.example.com");
let conn = conn.connect();
let conn = conn.authenticate("secret");
println!("{}", conn.query("SELECT 1"));
let _conn = conn.disconnect();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_door_transitions() {
let door = Door::<Open>::new("steel");
let door = door.close();
let door = door.lock();
let door = door.unlock();
let _door = door.open();
}
#[test]
fn test_runtime_door() {
let mut door = RuntimeDoor::new("wood");
assert_eq!(door.state, DoorState::Open);
assert!(door.close().is_ok());
assert_eq!(door.state, DoorState::Closed);
assert!(door.close().is_err());
}
#[test]
fn test_connection_protocol() {
let conn = Connection::<Disconnected>::new("localhost");
let conn = conn.connect();
let conn = conn.authenticate("pass");
let result = conn.query("SELECT 1");
assert!(result.contains("SELECT 1"));
let _disconnected = conn.disconnect();
}
}
(* Example 130: Typestate Pattern โ State Machines in Types *)
(* Approach 1: GADT-based state machine *)
type open_state = Open_s
type closed_state = Closed_s
type locked_state = Locked_s
type _ door =
| OpenDoor : open_state door
| ClosedDoor : closed_state door
| LockedDoor : locked_state door
let close_door : open_state door -> closed_state door = fun _ -> ClosedDoor
let open_door : closed_state door -> open_state door = fun _ -> OpenDoor
let lock_door : closed_state door -> locked_state door = fun _ -> LockedDoor
let unlock_door : locked_state door -> closed_state door = fun _ -> ClosedDoor
(* Approach 2: Module-based state machine *)
module type DOOR_STATE = sig type t val name : string end
module Open : DOOR_STATE = struct type t = open_state let name = "open" end
module Closed : DOOR_STATE = struct type t = closed_state let name = "closed" end
module Locked : DOOR_STATE = struct type t = locked_state let name = "locked" end
(* Approach 3: Phantom type state *)
type 'state door_p = { material : string }
let make_open_door material : open_state door_p = { material }
let close_p (d : open_state door_p) : closed_state door_p = { material = d.material }
let open_p (d : closed_state door_p) : open_state door_p = { material = d.material }
let lock_p (d : closed_state door_p) : locked_state door_p = { material = d.material }
let unlock_p (d : locked_state door_p) : closed_state door_p = { material = d.material }
(* Tests *)
let () =
let d = OpenDoor in
let d = close_door d in
let d = lock_door d in
let d = unlock_door d in
let _ = open_door d in
let d = make_open_door "wood" in
let d = close_p d in
let d = lock_p d in
let d = unlock_p d in
let d = open_p d in
assert (d.material = "wood");
Printf.printf "โ All tests passed\n"
๐ Detailed Comparison
Comparison: Typestate Pattern
State Machine Door
OCaml
๐ช Show OCaml equivalent
type open_state = Open_s
type closed_state = Closed_s
type _ door =
| OpenDoor : open_state door
| ClosedDoor : closed_state door
let close_door : open_state door -> closed_state door =
fun _ -> ClosedDoor
let open_door : closed_state door -> open_state door =
fun _ -> OpenDoorRust
struct Open;
struct Closed;
struct Door<State> {
material: String,
_state: PhantomData<State>,
}
impl Door<Open> {
fn close(self) -> Door<Closed> {
Door { material: self.material, _state: PhantomData }
}
}
impl Door<Closed> {
fn open(self) -> Door<Open> {
Door { material: self.material, _state: PhantomData }
}
}