Home Design Patterns Typestate
Typestate
Ownership and LifetimesPut 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.
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>`
}
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.