734-typestate-basics — Typestate Basics
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
Red, Green, Yellow)PhantomData<State> to carry type-level state without runtime overheadself and returning a different typesize_of::<Light<Red>>() equals size_of::<Light<Green>>() — zero overheadCode 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
impl Light<Red> { fn go(self) -> Light<Green> }; OCaml uses phantom type variables and module signatures to restrict operations.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
// }
}#[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
blink() method to Light<Yellow> that returns Light<Yellow> (self-transition), and verify it compiles without allowing blink() on other states.Idle, ItemSelected, PaymentReceived, and Dispensing. Enforce that payment can only happen after selection.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.