ExamplesBy LevelBy TopicLearning Paths
130 Advanced

Typestate Pattern — State Machines in Types

Functional Programming

Tutorial

The Problem

State machines govern everything from network connections to file handles to UI workflows. Typically, invalid transitions (locking an open door, reading from a closed file, sending on a disconnected socket) fail at runtime with errors or panics. The typestate pattern encodes each valid state as a distinct type, so invalid transitions become compile errors. This eliminates entire classes of runtime bugs with zero overhead — the state information disappears at compile time.

🎯 Learning Outcomes

  • • Understand how phantom type parameters encode state without runtime cost
  • • Learn to implement consuming state transitions that prevent reuse of the old state
  • • See how methods are selectively available only in the correct state
  • • Recognize real-world applications: connection lifecycle, file handles, builder patterns, protocol sequences
  • Code Example

    use std::marker::PhantomData;
    
    pub struct Open;
    pub struct Closed;
    pub struct Locked;
    
    pub struct Door<State> {
        pub material: String,
        _state: PhantomData<State>,
    }
    
    impl Door<Open> {
        pub fn close(self) -> Door<Closed> {
            Door { material: self.material, _state: PhantomData }
        }
        pub fn walk_through(&self) -> String {
            format!("Walking through {} door", self.material)
        }
    }
    
    impl Door<Closed> {
        pub fn open(self) -> Door<Open> {
            Door { material: self.material, _state: PhantomData }
        }
        pub fn lock(self) -> Door<Locked> {
            Door { material: self.material, _state: PhantomData }
        }
    }
    
    impl Door<Locked> {
        pub fn unlock(self) -> Door<Closed> {
            Door { material: self.material, _state: PhantomData }
        }
    }

    Key Differences

  • Affine types: Rust's move semantics ensure the old state value cannot be used after a transition; OCaml retains the old binding (GC-managed), relying on programmer discipline.
  • Method availability: Rust's impl blocks per state restrict which methods exist on each type; OCaml typically uses a single module with runtime guards or phantom-typed functions.
  • Zero-sized states: Both Open, Closed, Locked in Rust and their OCaml equivalents are zero-sized; PhantomData in Rust explicitly marks the field as carrying no data.
  • Composability: Rust typestate works for any number of state dimensions (multiple phantom parameters); OCaml's phantom approach scales similarly but with more boilerplate.
  • OCaml Approach

    OCaml can encode the typestate pattern using phantom types and GADTs:

    type open_ = Open
    type closed = Closed
    type 'state door = { material: string }
    let close : open_ door -> closed door = fun d -> d
    let open_ : closed door -> open_ door = fun d -> d
    

    The transition functions change the phantom parameter. OCaml's approach is syntactically lighter, but state transitions do not consume the old value — the programmer must not use the old binding after transitioning (enforced by convention, not the type system).

    Full Source

    #![allow(clippy::all)]
    // Example 130: Typestate Pattern — State Machines in Types
    //
    // The typestate pattern uses phantom type parameters to encode state in the type
    // system, making invalid state transitions a compile-time error rather than a
    // runtime panic. Each state is a zero-sized marker struct; the main struct carries
    // a PhantomData<State> field so Rust tracks the state without any runtime cost.
    
    use std::marker::PhantomData;
    
    // ---------------------------------------------------------------------------
    // Approach 1: Door state machine
    // ---------------------------------------------------------------------------
    
    /// Zero-sized marker structs — they carry no data, only type information.
    pub struct Open;
    pub struct Closed;
    pub struct Locked;
    
    /// A door whose valid operations depend entirely on its current state.
    /// `Door<Open>`, `Door<Closed>`, and `Door<Locked>` are three *different* types.
    pub struct Door<State> {
        pub material: String,
        _state: PhantomData<State>,
    }
    
    // --- Open state: can close or walk through, but NOT lock directly ---
    impl Door<Open> {
        pub fn new(material: &str) -> Self {
            Door {
                material: material.to_string(),
                _state: PhantomData,
            }
        }
    
        /// Consuming `self` ensures the old `Door<Open>` can no longer be used.
        pub fn close(self) -> Door<Closed> {
            Door {
                material: self.material,
                _state: PhantomData,
            }
        }
    
        pub fn walk_through(&self) -> String {
            format!("Walking through {} door", self.material)
        }
    }
    
    // --- Closed state: can open or lock, but NOT walk through ---
    impl Door<Closed> {
        pub fn open(self) -> Door<Open> {
            Door {
                material: self.material,
                _state: PhantomData,
            }
        }
    
        pub fn lock(self) -> Door<Locked> {
            Door {
                material: self.material,
                _state: PhantomData,
            }
        }
    }
    
    // --- Locked state: can only unlock ---
    impl Door<Locked> {
        pub fn unlock(self) -> Door<Closed> {
            Door {
                material: self.material,
                _state: PhantomData,
            }
        }
    }
    
    // All states share the `state_name` helper via a blanket trait.
    pub trait StateName {
        fn state_name(&self) -> &'static str;
    }
    
    impl StateName for Door<Open> {
        fn state_name(&self) -> &'static str {
            "open"
        }
    }
    impl StateName for Door<Closed> {
        fn state_name(&self) -> &'static str {
            "closed"
        }
    }
    impl StateName for Door<Locked> {
        fn state_name(&self) -> &'static str {
            "locked"
        }
    }
    
    // ---------------------------------------------------------------------------
    // Approach 2: Database connection state machine
    // ---------------------------------------------------------------------------
    // Models: Disconnected → Connected → Authenticated → (query allowed)
    
    pub struct Disconnected;
    pub struct Connected;
    pub struct Authenticated;
    
    pub struct DbConnection<State> {
        pub host: String,
        _state: PhantomData<State>,
    }
    
    impl DbConnection<Disconnected> {
        pub fn new(host: &str) -> Self {
            DbConnection {
                host: host.to_string(),
                _state: PhantomData,
            }
        }
    
        pub fn connect(self) -> DbConnection<Connected> {
            DbConnection {
                host: self.host,
                _state: PhantomData,
            }
        }
    }
    
    impl DbConnection<Connected> {
        pub fn authenticate(self, _password: &str) -> DbConnection<Authenticated> {
            DbConnection {
                host: self.host,
                _state: PhantomData,
            }
        }
    }
    
    impl DbConnection<Authenticated> {
        /// Only callable once you have proved (at compile time) that you authenticated.
        pub fn query(&self, sql: &str) -> String {
            format!("Executing '{}' on {}", sql, self.host)
        }
    
        pub fn disconnect(self) -> DbConnection<Disconnected> {
            DbConnection {
                host: self.host,
                _state: PhantomData,
            }
        }
    }
    
    // ---------------------------------------------------------------------------
    // Approach 3: Builder typestate — HTTP request builder
    // ---------------------------------------------------------------------------
    // Ensures a URL is set before the request can be sent.
    
    pub struct NoUrl;
    pub struct HasUrl;
    
    pub struct HttpRequest<UrlState> {
        url: Option<String>,
        body: Option<String>,
        _state: PhantomData<UrlState>,
    }
    
    impl HttpRequest<NoUrl> {
        pub fn new() -> Self {
            HttpRequest {
                url: None,
                body: None,
                _state: PhantomData,
            }
        }
    
        pub fn url(self, url: &str) -> HttpRequest<HasUrl> {
            HttpRequest {
                url: Some(url.to_string()),
                body: self.body,
                _state: PhantomData,
            }
        }
    }
    
    impl Default for HttpRequest<NoUrl> {
        fn default() -> Self {
            Self::new()
        }
    }
    
    impl HttpRequest<HasUrl> {
        pub fn body(mut self, body: &str) -> Self {
            self.body = Some(body.to_string());
            self
        }
    
        /// Only reachable when a URL has been provided — guaranteed by the type.
        pub fn send(self) -> String {
            let url = self.url.expect("HasUrl guarantees url is Some");
            match self.body {
                Some(b) => format!("POST {} with body: {}", url, b),
                None => format!("GET {}", url),
            }
        }
    }
    
    // ---------------------------------------------------------------------------
    // Tests
    // ---------------------------------------------------------------------------
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        // --- Door tests ---
    
        #[test]
        fn test_door_open_close_open() {
            let door = Door::<Open>::new("oak");
            assert_eq!(door.state_name(), "open");
    
            let door = door.close();
            assert_eq!(door.state_name(), "closed");
    
            let door = door.open();
            assert_eq!(door.state_name(), "open");
        }
    
        #[test]
        fn test_door_full_cycle_with_lock() {
            let door = Door::<Open>::new("steel");
            let door = door.close();
            let door = door.lock();
            assert_eq!(door.state_name(), "locked");
    
            let door = door.unlock();
            assert_eq!(door.state_name(), "closed");
    
            let door = door.open();
            let msg = door.walk_through();
            assert_eq!(msg, "Walking through steel door");
        }
    
        #[test]
        fn test_door_material_preserved_across_transitions() {
            let door = Door::<Open>::new("mahogany");
            let closed = door.close();
            assert_eq!(closed.material, "mahogany");
    
            let locked = closed.lock();
            assert_eq!(locked.material, "mahogany");
    
            let closed2 = locked.unlock();
            assert_eq!(closed2.material, "mahogany");
        }
    
        // --- DbConnection tests ---
    
        #[test]
        fn test_db_connection_full_flow() {
            let conn = DbConnection::<Disconnected>::new("localhost:5432");
            let conn = conn.connect();
            let conn = conn.authenticate("secret");
            let result = conn.query("SELECT 1");
            assert_eq!(result, "Executing 'SELECT 1' on localhost:5432");
    
            let _disconnected = conn.disconnect();
        }
    
        #[test]
        fn test_db_connection_host_preserved() {
            let host = "db.example.com:5432";
            let conn = DbConnection::<Disconnected>::new(host)
                .connect()
                .authenticate("pw");
            assert_eq!(conn.host, host);
        }
    
        // --- HttpRequest tests ---
    
        #[test]
        fn test_http_get_request() {
            let result = HttpRequest::new().url("https://example.com/api").send();
            assert_eq!(result, "GET https://example.com/api");
        }
    
        #[test]
        fn test_http_post_request_with_body() {
            let result = HttpRequest::new()
                .url("https://example.com/api")
                .body(r#"{"key":"value"}"#)
                .send();
            assert_eq!(
                result,
                r#"POST https://example.com/api with body: {"key":"value"}"#
            );
        }
    
        #[test]
        fn test_http_default_is_no_url() {
            // HttpRequest<NoUrl>::default() compiles; calling .send() would not.
            let req: HttpRequest<NoUrl> = HttpRequest::default();
            // We can only call .url() on it, not .send().
            let result = req.url("https://example.com").send();
            assert_eq!(result, "GET https://example.com");
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        // --- Door tests ---
    
        #[test]
        fn test_door_open_close_open() {
            let door = Door::<Open>::new("oak");
            assert_eq!(door.state_name(), "open");
    
            let door = door.close();
            assert_eq!(door.state_name(), "closed");
    
            let door = door.open();
            assert_eq!(door.state_name(), "open");
        }
    
        #[test]
        fn test_door_full_cycle_with_lock() {
            let door = Door::<Open>::new("steel");
            let door = door.close();
            let door = door.lock();
            assert_eq!(door.state_name(), "locked");
    
            let door = door.unlock();
            assert_eq!(door.state_name(), "closed");
    
            let door = door.open();
            let msg = door.walk_through();
            assert_eq!(msg, "Walking through steel door");
        }
    
        #[test]
        fn test_door_material_preserved_across_transitions() {
            let door = Door::<Open>::new("mahogany");
            let closed = door.close();
            assert_eq!(closed.material, "mahogany");
    
            let locked = closed.lock();
            assert_eq!(locked.material, "mahogany");
    
            let closed2 = locked.unlock();
            assert_eq!(closed2.material, "mahogany");
        }
    
        // --- DbConnection tests ---
    
        #[test]
        fn test_db_connection_full_flow() {
            let conn = DbConnection::<Disconnected>::new("localhost:5432");
            let conn = conn.connect();
            let conn = conn.authenticate("secret");
            let result = conn.query("SELECT 1");
            assert_eq!(result, "Executing 'SELECT 1' on localhost:5432");
    
            let _disconnected = conn.disconnect();
        }
    
        #[test]
        fn test_db_connection_host_preserved() {
            let host = "db.example.com:5432";
            let conn = DbConnection::<Disconnected>::new(host)
                .connect()
                .authenticate("pw");
            assert_eq!(conn.host, host);
        }
    
        // --- HttpRequest tests ---
    
        #[test]
        fn test_http_get_request() {
            let result = HttpRequest::new().url("https://example.com/api").send();
            assert_eq!(result, "GET https://example.com/api");
        }
    
        #[test]
        fn test_http_post_request_with_body() {
            let result = HttpRequest::new()
                .url("https://example.com/api")
                .body(r#"{"key":"value"}"#)
                .send();
            assert_eq!(
                result,
                r#"POST https://example.com/api with body: {"key":"value"}"#
            );
        }
    
        #[test]
        fn test_http_default_is_no_url() {
            // HttpRequest<NoUrl>::default() compiles; calling .send() would not.
            let req: HttpRequest<NoUrl> = HttpRequest::default();
            // We can only call .url() on it, not .send().
            let result = req.url("https://example.com").send();
            assert_eq!(result, "GET https://example.com");
        }
    }

    Deep Comparison

    OCaml vs Rust: Typestate Pattern

    Side-by-Side Code

    OCaml (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
    

    Rust (idiomatic — phantom type parameters)

    use std::marker::PhantomData;
    
    pub struct Open;
    pub struct Closed;
    pub struct Locked;
    
    pub struct Door<State> {
        pub material: String,
        _state: PhantomData<State>,
    }
    
    impl Door<Open> {
        pub fn close(self) -> Door<Closed> {
            Door { material: self.material, _state: PhantomData }
        }
        pub fn walk_through(&self) -> String {
            format!("Walking through {} door", self.material)
        }
    }
    
    impl Door<Closed> {
        pub fn open(self) -> Door<Open> {
            Door { material: self.material, _state: PhantomData }
        }
        pub fn lock(self) -> Door<Locked> {
            Door { material: self.material, _state: PhantomData }
        }
    }
    
    impl Door<Locked> {
        pub fn unlock(self) -> Door<Closed> {
            Door { material: self.material, _state: PhantomData }
        }
    }
    

    Rust (builder typestate — URL required before send)

    pub struct NoUrl;
    pub struct HasUrl;
    
    pub struct HttpRequest<UrlState> {
        url: Option<String>,
        body: Option<String>,
        _state: PhantomData<UrlState>,
    }
    
    impl HttpRequest<NoUrl> {
        pub fn url(self, url: &str) -> HttpRequest<HasUrl> {
            HttpRequest { url: Some(url.to_string()), body: self.body, _state: PhantomData }
        }
    }
    
    impl HttpRequest<HasUrl> {
        pub fn send(self) -> String { /* guaranteed to have url */ }
    }
    

    Type Signatures

    ConceptOCamlRust
    State markertype open_state = Open_spub struct Open;
    Parameterised containertype _ door = ... (GADT)struct Door<State>
    Phantom fieldimplicit in GADT_state: PhantomData<State>
    Transition functionclose_door : open_state door -> closed_state doorfn close(self) -> Door<Closed>
    Method availabilityconstrained by GADT constructorconstrained by impl Door<Open>

    Key Insights

  • GADT vs phantom generics: OCaml uses GADTs to index a single type by a phantom state type; Rust uses a generic parameter and PhantomData to achieve the same effect without GADTs.
  • Method-level enforcement: Rust's impl blocks are per concrete instantiation (impl Door<Open>), so close() literally does not exist on Door<Closed> — no trait, no method, the compiler cannot even see it.
  • Zero runtime cost: Both approaches are zero-cost. OCaml's GADT state values are erased; Rust's PhantomData<State> is a zero-sized type with no memory footprint.
  • Consuming transitions: Rust transitions consume self by value, making it impossible to hold a reference to the "old" state after the transition — a stronger guarantee than OCaml's functional style.
  • Builder pattern synergy: The typestate pattern composes naturally with the builder pattern in Rust, letting you enforce that required fields are set (e.g., a URL) before certain operations (e.g., send()) are even callable.
  • When to Use Each Style

    Use idiomatic Rust phantom generics when: you need compile-time protocol enforcement with zero runtime cost — API clients, resource lifecycle management (open/close/lock), builder patterns where some fields must precede others.

    Use OCaml GADTs when: you need to work with heterogeneous collections of state-indexed values in a single algebraic type, or when you want exhaustive pattern matching across all states in one match expression.

    Exercises

  • Add a Broken state to the door and define break_open(self) -> Door<Broken> callable only from Door<Locked>.
  • Implement a TcpConnection typestate with Unconnected, Connected, and Closed states and appropriate methods on each.
  • Build a three-stage builder using typestate: Builder<NoName>Builder<Named>Builder<Complete>, where build() is only available on Builder<Complete>.
  • Open Source Repos