A state machine has a small number of valid moves and an infinite number of invalid ones. The interesting question is which side of that line the compiler sits on. Most code keeps the rules in your head and finds a bad transition at runtime, if at all. Put the rules in the type system instead. There an illegal move is a red squiggle, and the build that ships never contained it.

The pieces are already in the language. A discriminated union models the states. A conditional type over a transition map decides whether one state can reach another. A phantom type parameter threads the current state through a runtime value, so its method set narrows as the machine advances. None of this needs a library.

States as a discriminated union

Start with a request lifecycle. The naive shape is one object with optional fields. That lets you read data while still loading and forces a ! everywhere. A discriminated union ties each payload to the state that owns it:

type RequestState =
  | { status: 'idle' }
  | { status: 'loading'; startedAt: number }
  | { status: 'success'; data: string }
  | { status: 'error'; error: Error };

function render(s: RequestState): string {
  switch (s.status) {
    case 'idle':    return 'waiting';
    case 'loading': return `since ${s.startedAt}`; // startedAt only here
    case 'success': return s.data;                 // data only here
    case 'error':   return s.error.message;        // error only here
  }
}

The status literal is the discriminant. Inside each case, control-flow analysis narrows s to one member, so s.data resolves in 'success' and is a compile error anywhere else. This is the machinery from discriminated unions and exhaustiveness: drop a case and a never-typed default catches it. The payload cannot exist apart from its state.

What this union does not encode is order. Nothing here stops idle from jumping straight to success. The states are typed. The edges between them are not.

Transitions as a type-level map

Model the edges as data, then write a conditional that reads it. The map is a record from each state to the events it accepts, and from each event to the state it produces:

type State = 'idle' | 'loading' | 'success' | 'error';
type Event = 'FETCH' | 'RESOLVE' | 'REJECT' | 'RESET';

type Transitions = {
  idle:    { FETCH: 'loading' };
  loading: { RESOLVE: 'success'; REJECT: 'error' };
  success: { RESET: 'idle' };
  error:   { RESET: 'idle'; FETCH: 'loading' };
};

// Resolve the target state, or `never` if E is illegal from S.
type Next<S extends State, E extends Event> =
  E extends keyof Transitions[S] ? Transitions[S][E] : never;

type A = Next<'idle', 'FETCH'>;       // 'loading'
type B = Next<'loading', 'RESOLVE'>;  // 'success'
type C = Next<'idle', 'RESOLVE'>;     // never  (idle has no RESOLVE edge)
type D = Next<'success', 'FETCH'>;    // never  (success only accepts RESET)

Transitions[S] indexes the map by the current state. The conditional E extends keyof Transitions[S] asks whether the event is a key on that state’s edge set. If so, a second index Transitions[S][E] returns the destination. A move with no edge falls through to never, the type-level way of saying “this does not exist.”

never is more than a marker. Wire it into a send signature and an illegal call fails to typecheck rather than producing a junk state:

declare function send<S extends State, E extends Event>(
  state: S,
  event: Next<S, E> extends never ? never : E,
): Next<S, E>;

const r1 = send('idle', 'FETCH');      // r1: 'loading'
const r2 = send('loading', 'REJECT');  // r2: 'error'
// @ts-expect-error  RESOLVE is not a legal event from 'idle'
const r3 = send('idle', 'RESOLVE');

When Next<S, E> is never, the second parameter collapses to never, and no event value satisfies it. The protocol now lives in the signature. You cannot call send wrong and get a valid program.

Methods that depend on the state: phantom parameters

The map above validates transitions, but the caller still passes the current state by hand. Push the state into a type parameter on a runtime value, and the value carries its own state. The methods available on it then change as the machine moves.

A phantom type parameter appears in a type’s signature without a matching runtime field. Here Connection<S> tags a connection with its lifecycle state, and each method returns a Connection in the next state rather than mutating in place:

declare const tag: unique symbol;

interface Connection<S extends 'closed' | 'open'> {
  readonly [tag]: S; // phantom: present in the type, never read at runtime
}

declare function connect(): Connection<'closed'>;
declare function open(c: Connection<'closed'>): Connection<'open'>;
declare function send(c: Connection<'open'>, msg: string): Connection<'open'>;
declare function close(c: Connection<'open'>): Connection<'closed'>;

const a = connect();        // Connection<'closed'>
const b = open(a);          // Connection<'open'>
const c = send(b, 'ping');  // Connection<'open'>
const d = close(c);         // Connection<'closed'>

// @ts-expect-error  cannot send on a closed connection
send(a, 'ping');
// @ts-expect-error  cannot open a connection that is already open
open(b);

send accepts only Connection<'open'>, so passing the closed a is rejected at the call site. The unique symbol key keeps two connection states from being assignable to each other even when they share fields, which a plain string discriminant would allow.

The same idea drives an ordering builder, where one method must run before another. Track the completed steps as a union, then gate each method on its this type. The union has to surface in a real field, not only in this positions. A this parameter is contravariant, so a phantom that appears nowhere else leaves the variance unanchored, and the gate never fires. Carry the progress in a readonly done field of type Record<Done, true>, and each completed step adds a required key:

interface Builder<Done extends 'a' | 'b' = never> {
  readonly done: Record<Done, true>; // covariant occurrence: anchors the phantom so `this` gates
  a(this: Builder<never>): Builder<'a'>;
  b(this: Builder<'a'>): Builder<'a' | 'b'>;
  build(this: Builder<'a' | 'b'>): string;
}

declare const start: Builder;

start.a().b().build();   // ok: a then b then build

// @ts-expect-error  b() requires a() first
start.b();
// @ts-expect-error  build() requires both a() and b()
start.a().build();

The this parameter is the gate. b declares this: Builder<'a'>, whose done field is Record<'a', true> and so requires an a key. start is a Builder<never> with done: Record<never, true>, an empty object, so start.b() is rejected: it lacks the a key. Each step returns a wider Done union, growing done and unlocking the next method. start.a().build() fails the same way, since build needs both keys and only a is present.

Where the archive already does this

The experiments are state machines whether or not they are labeled as such. Wordle is the request lifecycle under different names. A game is playing until a guess matches, then it transitions to won, or to lost once the guess budget is spent. The legal moves are exactly the edges of a transition map, and a guess submitted after the game ends is a transition with no edge. Encode that as Next<S, E> and a finished game has no submit move to call.

Candy Crush evolves a board one state to the next. A swap produces a new board type, matches resolve into another, gravity settles into a third. Each step is a function from one state type to its successor, the shape of the connection example, with the board standing in for the phantom tag. The board’s type after a swap differs from its type before, which is what lets the rules of the next step attach to it.

The throughline: a state machine is a transition function plus a way to remember where you are. The type system gives you both. A conditional type for the function, a type parameter for the memory. Counting how many distinct states a machine can occupy is itself a type-level exercise, the kind covered in counting without numbers.