Home Design Patterns Typestate

Typestate

Ownership and Lifetimes

Put the state machine in the type system, so an illegal transition fails to compile.

Purpose

An object that has states usually stores which one it is in, and checks that field before doing anything that depends on it. Typestate deletes the field and the check. Each state becomes a separate type, each operation is defined only on the states where it is legal, and calling it anywhere else does not compile.

The difference from a runtime check is not that it is faster, though it is. It is that an entire category of error stops being something you test for and starts being something you cannot express.

Design

Give the object a type parameter representing its state, and create an empty struct for each state. The parameter is carried by PhantomData, which occupies no space and exists only to give the compiler something to track.

Transitions take self by value and return the next type. This is the second half of the mechanism and the half people miss: because the transition consumes the object, the previous state no longer exists. You cannot hold onto a stale handle and use it after the transition, because the compiler moved it.

UML class diagram: three types Arm<Unpowered>, Arm<Idle> and Arm<Homed>, each exposing only the operations legal in that state.
Three types rather than one type with three values. The operation you want is only reachable from the state that permits it.

Example

The Facade page describes bringing an Autobot arm to a ready state: power the servo bus, wait for the drives, home each axis in a specific order so the arm cannot collide with its own fixture, load the tool calibration, zero the force sensors, release the interlock. Six subsystems and an ordering constraint between nearly every pair. One application homed the axes in the wrong order and had been quietly bending a fixture for months.

A Facade fixes that by putting the sequence in one place and giving it a name. It is a real improvement and it is what I would do in C#. But it is a convention, not a guarantee. The subsystems are still reachable directly, which is deliberate, and so nothing stops a new application from calling them in the wrong order all over again. The Facade documents the ordering. It does not enforce it.

Typestate enforces it. An arm that has not been homed does not have a move_to method to call.

use std::marker::PhantomData;

struct Unpowered;
struct Idle;
struct Homed;

struct Arm<S> {
    id: u8,
    _state: PhantomData<S>,
}

impl Arm<Unpowered> {
    fn new(id: u8) -> Arm<Unpowered> {
        Arm { id, _state: PhantomData }
    }

    fn power_up(self) -> Arm<Idle> {
        // energize the servo bus, wait for drives ready
        Arm { id: self.id, _state: PhantomData }
    }
}

impl Arm<Idle> {
    fn home(self) -> Arm<Homed> {
        // run the homing sequence in the safe axis order
        Arm { id: self.id, _state: PhantomData }
    }
}

impl Arm<Homed> {
    fn move_to(&self, x: i32, y: i32) {
        // safe by construction: this arm has been homed
    }
}

fn main() {
    let arm = Arm::new(1).power_up().home();
    arm.move_to(120, 40);

    // let arm = Arm::new(2);
    // arm.move_to(120, 40);
    // error: no method named `move_to` found for `Arm<Unpowered>`
}
The bent fixture is not caught by a test here. It is caught by the compiler, in the editor, before the code has ever been run.

Seen in the Wild

The Rust embedded ecosystem is built on this. In embedded-hal and the device crates beneath it, a microcontroller pin is typed by its configuration, so a pin currently set as a floating input simply has no method for writing a value to it, and reconfiguring it consumes the old pin and returns a new one. Elsewhere, std::fs::OpenOptions is a softer version of the idea, and typed builder patterns that will not compile until every required field is set are typestate applied to construction. The concept predates Rust and comes from work on typestate analysis in the 1980s.

Comparisons

  • State solves the identical problem with the opposite mechanism. GoF State moves the transition table into a set of runtime objects; typestate moves it into types. State handles a machine whose shape is decided at runtime; typestate handles one whose shape is known when you compile.
  • Builder is where most people meet typestate without knowing the name, in a builder that refuses to compile build() until the mandatory fields have been supplied.
  • Facade is the same problem addressed by convention rather than by the compiler, as described above.
  • Newtype is the same instinct applied to a single value instead of an object's lifecycle.
  • Marker Interface is close kin: the empty state structs are markers, used to carry information no runtime value needs to hold.

Why It Exists

My take

Typestate exists because state machine rules are almost always written down somewhere that cannot enforce them. A comment. A wiki page. A sequence diagram from four years ago. The knowledge is real and correct and completely inert, and every new caller has to rediscover it or be told.

I find this the most interesting pattern on the site, because it is the clearest case of a compiler doing work that has historically been done by discipline. The Facade page argues that a Facade converts tribal knowledge into a function signature, and I stand by that. Typestate goes one step further and converts it into a type error. The difference matters most at exactly the moment discipline fails, which is when someone unfamiliar with the system is working quickly under pressure.

The reason it appears in embedded Rust rather than in web frameworks is worth noticing. When the cost of an illegal transition is a wrong HTTP response, a runtime check is fine. When it is a servo driving an arm into a fixture, or a pin configured as an output being shorted to ground, the economics change completely. Every project I worked on where the software could damage the hardware would have been better with this, and none of them had a language that could express it.

Criticisms

The error messages are bad, and they get worse as the technique gets more sophisticated. A missed transition produces a complaint about a missing method on a generic type, which tells you nothing about the state machine you actually violated. Library authors work around this with custom diagnostics; most people do not.

The types multiply. Every state is a type, and any function that must accept an arm in more than one state needs a generic parameter with a trait bound, or a wrapping enum, and the clean version of the pattern starts eroding immediately. Storing arms in a collection is the point where it usually breaks down: a Vec of things in different states is not a thing typestate lets you have without boxing them back into a runtime representation, at which point you have paid for the pattern and returned to State.

It cannot handle states that are only known at runtime. If the sequence comes from a configuration file, or the arm can be reset into an earlier state by an external signal, the compiler cannot help you and this pattern is the wrong tool. It is also unpleasant to refactor: adding a state to a mature typestate API touches every impl block and every signature that mentioned the old ones.