Skip to main content

State — Killing Invalid Transitions with Types

This chapter compares a hierarchy of state classes against representing state with a union type.

The problem this pattern set out to solve

GoF catalogs State as a pattern that lets an object's behavior change when its internal state changes1.

Holding state in a string or a number and branching on it at the top of every method means rewriting every method each time a state is added. State splits the states into separate objects and delegates the work to the current state object, which erases that branching.

What is worth pinning down here is the problem State sets out to solve1. Two things are listed: letting behavior change according to internal state, and letting state-specific behavior be defined independently. Stopping a call such as "selecting a product before inserting a coin" lies outside that intent.

Writing it straightforwardly with classes

The subject here is a vending machine. You create a class per state and implement a shared shape on all of them.

type Product = 'cola' | 'tea';
const PRICES: Record<Product, number> = {cola: 150, tea: 130};

interface MachineState {
insertCoin(amount: number): MachineState;
select(product: Product): MachineState;
takeOut(): {next: MachineState; product: Product; change: number};
}

class IdleState implements MachineState {
insertCoin(amount: number): MachineState {
return new PaidState(amount);
}

select(_product: Product): MachineState {
// An operation that cannot be called in this state. But the type cannot stop it
throw new Error('Insert a coin first');
}

takeOut(): {next: MachineState; product: Product; change: number} {
throw new Error('No product available to take out');
}
}

class PaidState implements MachineState {
constructor(private readonly credit: number) {}

insertCoin(amount: number): MachineState {
return new PaidState(this.credit + amount);
}

select(product: Product): MachineState {
const price = PRICES[product];
return this.credit < price ? this : new DispensingState(product, this.credit - price);
}

takeOut(): {next: MachineState; product: Product; change: number} {
throw new Error('No product available to take out');
}
}

class DispensingState implements MachineState {
constructor(
readonly product: Product,
readonly change: number,
) {}

insertCoin(_amount: number): MachineState {
throw new Error('A product is being dispensed');
}

select(_product: Product): MachineState {
throw new Error('A product is being dispensed');
}

// As with takeOut in the union version, returns the product and the change
takeOut(): {next: MachineState; product: Product; change: number} {
return {next: new IdleState(), product: this.product, change: this.change};
}
}

The branching is indeed gone. In its place, five throw new Error calls have appeared. That is because once every state implements a shared shape, even operations that mean nothing in that state still need an implementation. From the caller's side, state.select(...) looks callable in any state.

Replacing it with language features

Represent the state with a discriminated union and make the transitions functions. What does the work here is that the parameter type of a function can name only the states in which that operation is allowed.

type Product = 'cola' | 'tea';
const PRICES: Record<Product, number> = {cola: 150, tea: 130};

type Idle = {phase: 'idle'};
type Paid = {phase: 'paid'; credit: number};
type Dispensing = {phase: 'dispensing'; product: Product; change: number};
type MachineState = Idle | Paid | Dispensing;

// Coins go in only while idle or paid. Not accepted while dispensing
function insertCoin(state: Idle | Paid, amount: number): Paid {
const current = state.phase === 'paid' ? state.credit : 0;
return {phase: 'paid', credit: current + amount};
}

// A product can be selected only while paid
function select(state: Paid, product: Product): Paid | Dispensing {
const price = PRICES[product];
if (state.credit < price) {
return state;
}
return {phase: 'dispensing', product, change: state.credit - price};
}

// At take-out the product and the change are already settled. The parameter type guarantees it
function takeOut(state: Dispensing): {next: Idle; product: Product; change: number} {
return {next: {phase: 'idle'}, product: state.product, change: state.change};
}

const paid = insertCoin({phase: 'idle'}, 150);
const chosen = select(paid, 'cola');
const delivered = chosen.phase === 'dispensing' ? takeOut(chosen) : undefined;

The throw new Error calls are gone, because "you cannot call this in that state" is now expressed by the parameter type rather than by an exception.

Example that produces a type error
// error TS2322: Type '"idle"' is not assignable to type '"paid"'.
const bad = select({phase: 'idle'}, 'cola');

Instead of noticing at runtime through an exception, it stops at compile time. What lay outside State's intent — the invalid operation — is what the type takes on.

When you want to handle the full list of states exhaustively, use switch with never. That is the same form covered in Composite.

How this series judges it

Verdict: conditional, though in most cases it is a discriminated union.

Pick a discriminated union when any of the following applies. In practice, usually one of them does.

  • Each state holds different data (there is no credit while idle)
  • Each state allows different operations
  • You want to save and restore the state (plain objects go straight into JSON)

Pick a hierarchy of state classes when the number of states will keep growing and every state carries the same set of operations. Under that condition, the benefit of adding just one class shows up.

The dividing line is the same as in Composite: whether it is the kinds of state or the operations that grow. With State, though, the set of operations often differs per state, and in that case placing a shared shape does not match reality. That is why the default leans toward a union type.

What would overturn this judgment

Narrowing operations through parameter types creates friction when the state is only known at runtime. A state received from outside arrives typed MachineState, so it needs a state.phase === 'paid' narrowing before it can be passed to select. That narrowing gets written at every call site and piles up when many places deal with transitions.

At that point you end up providing a single entry point that holds the current state and hands back only the permitted operations. Once you are there, the gap from writing it as a class is small.

One more thing: with many transitions, holding the transitions themselves as a table makes things clearer. Lining up functions makes the whole picture hard to see past a dozen or so transitions. That rule of thumb is this guide's judgment and is not based on measurement.

How this relates to existing articles

  • Conditionals covers discriminated unions and exhaustiveness checks. This chapter applies the same tools to state transitions and does not repeat the fundamentals.
  • Composite shares this chapter's axis of judgment. The only difference is trees there versus state transitions here; choosing by "do operations grow or do kinds grow" is the same.

Footnotes

  1. Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994). State is catalogued there as a behavioral pattern. The summary of the intent is this guide's own; we have not checked it against the original text. That the problems it addresses are the following two was confirmed via a secondary source: "An object should change its behavior when its internal state changes." and "State-specific behavior should be defined independently." Source: State pattern (Wikipedia). 2