ExamplesBy LevelBy TopicLearning Paths
734 Advanced

734-typestate-basics — Typestate Basics

Functional Programming

Tutorial

The Problem

State machines are everywhere: network connections, file handles, protocol sessions, UI wizards. The standard approach encodes state as a runtime enum and adds match checks before every operation. This moves errors from compile time to runtime — you can write socket.send(data) on a closed socket and only discover the bug at runtime. The typestate pattern encodes state in the type parameter itself, making invalid transitions a compile error. Zero-cost at runtime: all phantom data is erased.

🎯 Learning Outcomes

  • • Encode state as zero-sized marker types (Red, Green, Yellow)
  • • Use PhantomData<State> to carry type-level state without runtime overhead
  • • Implement state transitions by consuming self and returning a different type
  • • Understand why invalid transitions fail at compile time rather than runtime
  • • Verify that size_of::<Light<Red>>() equals size_of::<Light<Green>>() — zero overhead
  • Code Example

    #![allow(clippy::all)]
    /// 734: Typestate Basics — compile-time state machine
    /// Invalid transitions DO NOT compile. No runtime checks needed.
    use std::marker::PhantomData;
    
    // ── State marker types (zero-sized) ──────────────────────────────────────────
    
    pub struct Red;
    pub struct Green;
    pub struct Yellow;
    
    // ── Traffic Light ─────────────────────────────────────────────────────────────
    
    /// `PhantomData<State>` is zero bytes at runtime.
    /// The type parameter encodes which state we're in.
    pub struct Light<State> {
        _state: PhantomData<State>,
    }
    
    impl Light<Red> {
        /// Only way to create a light — must start Red.
        pub fn new() -> Self {
            println!("Light: Red");
            Light {
                _state: PhantomData,
            }
        }
    
        /// Red → Green (consumes self, returns different type)
        pub fn go(self) -> Light<Green> {
            println!("Light: Green");
            Light {
                _state: PhantomData,
            }
        }
    }
    
    impl Light<Green> {
        /// Green → Yellow
        pub fn slow(self) -> Light<Yellow> {
            println!("Light: Yellow");
            Light {
                _state: PhantomData,
            }
        }
    }
    
    impl Light<Yellow> {
        /// Yellow → Red
        pub fn stop(self) -> Light<Red> {
            println!("Light: Red");
            Light {
                _state: PhantomData,
            }
        }
    }
    
    // ── Size check ────────────────────────────────────────────────────────────────
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn full_cycle_compiles() {
            let red = Light::<Red>::new();
            let green = red.go();
            let yellow = green.slow();
            let _red2 = yellow.stop();
        }
    
        #[test]
        fn light_is_zero_sized() {
            assert_eq!(std::mem::size_of::<Light<Red>>(), 0);
            assert_eq!(std::mem::size_of::<Light<Green>>(), 0);
            assert_eq!(std::mem::size_of::<Light<Yellow>>(), 0);
        }
    
        // Uncommenting the test below would fail to COMPILE — which is the point:
        //
        // #[test]
        // fn invalid_transition_compile_error() {
        //     let r = Light::<Red>::new();
        //     let _ = r.slow();  // compile error: method not found
        // }
    }

    Key Differences

  • Syntax: Rust uses impl Light<Red> { fn go(self) -> Light<Green> }; OCaml uses phantom type variables and module signatures to restrict operations.
  • GADTs: OCaml's GADTs can encode complex state invariants that Rust's trait system cannot easily express without macros.
  • Consumption: Rust's move semantics make "consume self to transition" natural; OCaml's immutable values must use abstract types to prevent reuse of old states.
  • Zero cost: Both approaches are zero-cost at runtime — phantom types carry no runtime representation in either language.
  • OCaml Approach

    OCaml achieves typestate via phantom types in the module system. A ('state) light type carries a phantom type variable, and functors or abstract types prevent construction of invalid states. The classic technique uses empty types or abstract module types as state markers. OCaml's GADTs (generalized algebraic data types) provide an even more powerful mechanism for encoding state invariants directly in the type index.

    Full Source

    #![allow(clippy::all)]
    /// 734: Typestate Basics — compile-time state machine
    /// Invalid transitions DO NOT compile. No runtime checks needed.
    use std::marker::PhantomData;
    
    // ── State marker types (zero-sized) ──────────────────────────────────────────
    
    pub struct Red;
    pub struct Green;
    pub struct Yellow;
    
    // ── Traffic Light ─────────────────────────────────────────────────────────────
    
    /// `PhantomData<State>` is zero bytes at runtime.
    /// The type parameter encodes which state we're in.
    pub struct Light<State> {
        _state: PhantomData<State>,
    }
    
    impl Light<Red> {
        /// Only way to create a light — must start Red.
        pub fn new() -> Self {
            println!("Light: Red");
            Light {
                _state: PhantomData,
            }
        }
    
        /// Red → Green (consumes self, returns different type)
        pub fn go(self) -> Light<Green> {
            println!("Light: Green");
            Light {
                _state: PhantomData,
            }
        }
    }
    
    impl Light<Green> {
        /// Green → Yellow
        pub fn slow(self) -> Light<Yellow> {
            println!("Light: Yellow");
            Light {
                _state: PhantomData,
            }
        }
    }
    
    impl Light<Yellow> {
        /// Yellow → Red
        pub fn stop(self) -> Light<Red> {
            println!("Light: Red");
            Light {
                _state: PhantomData,
            }
        }
    }
    
    // ── Size check ────────────────────────────────────────────────────────────────
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn full_cycle_compiles() {
            let red = Light::<Red>::new();
            let green = red.go();
            let yellow = green.slow();
            let _red2 = yellow.stop();
        }
    
        #[test]
        fn light_is_zero_sized() {
            assert_eq!(std::mem::size_of::<Light<Red>>(), 0);
            assert_eq!(std::mem::size_of::<Light<Green>>(), 0);
            assert_eq!(std::mem::size_of::<Light<Yellow>>(), 0);
        }
    
        // Uncommenting the test below would fail to COMPILE — which is the point:
        //
        // #[test]
        // fn invalid_transition_compile_error() {
        //     let r = Light::<Red>::new();
        //     let _ = r.slow();  // compile error: method not found
        // }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn full_cycle_compiles() {
            let red = Light::<Red>::new();
            let green = red.go();
            let yellow = green.slow();
            let _red2 = yellow.stop();
        }
    
        #[test]
        fn light_is_zero_sized() {
            assert_eq!(std::mem::size_of::<Light<Red>>(), 0);
            assert_eq!(std::mem::size_of::<Light<Green>>(), 0);
            assert_eq!(std::mem::size_of::<Light<Yellow>>(), 0);
        }
    
        // Uncommenting the test below would fail to COMPILE — which is the point:
        //
        // #[test]
        // fn invalid_transition_compile_error() {
        //     let r = Light::<Red>::new();
        //     let _ = r.slow();  // compile error: method not found
        // }
    }

    Exercises

  • Add a blink() method to Light<Yellow> that returns Light<Yellow> (self-transition), and verify it compiles without allowing blink() on other states.
  • Model a vending machine with states Idle, ItemSelected, PaymentReceived, and Dispensing. Enforce that payment can only happen after selection.
  • Write a sequence function fn traffic_cycle(light: Light<Red>) -> Light<Red> that goes through a full Red→Green→Yellow→Red cycle and returns the light to its starting state.
  • Open Source Repos