๐Ÿฆ€ Functional Rust

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

Key Differences

ConceptOCamlRust
State encodingGADT: `type _ door = OpenDoor : open_state door \ClosedDoor : closed_state door`Phantom type param: `struct Door<State>` with marker structs
Method availabilityPattern-match phantom type in function signatureSeparate `impl Door<Open>`, `impl Door<Closed>` blocks
Value consumptionType annotation, value not consumed`self` taken by value โ€” old state is moved away, unusable
Zero runtime costDepends 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 _ -> OpenDoor

Rust

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 }
 }
}

Key Insight

Rust's `self` consumption means the old state is gone after transition โ€” you can't accidentally use a closed door as if it were open. OCaml achieves this through type annotations but the value isn't consumed.