- Book: The TypeScript Type System — From Generics to DSL-Level Types
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
An order goes from placed to paid to shipped to delivered, and then a support agent clicks the wrong button and it goes to cancelled. The webhook fires. The warehouse system gets a cancellation for a parcel that is sitting on someone's porch. The refund queue picks it up. The customer keeps the slippers and gets the money back.
Nobody coded that path. Nobody disallowed it either. The handler took a state and an event and it wrote them down. The state was a string. The event was a string. Two strings concatenated cleanly into a third string, and the database happily stored cancelled over delivered because the column is varchar and varchar does not have opinions.
Every order-management codebase you have read has this hole. Most of them paper it with a switch and a wall of if (currentStatus === "delivered" && event !== "cancel"). The if wall is read carefully on the day it is written and never again, and one of those && flips wrong six months later, and a customer keeps the slippers.
The state machine is the patch. The compile-time kind, not the runtime kind with a useMachine hook and a visualiser. States are types and events are types. The transition table is typed. delivered + cancel resolves to never, and the checker refuses it before the tests run.
Sixty lines of TypeScript, one file, no dependency.
States and Events as Discriminated Unions
The first move is the same one a discriminated-union post starts with: pick a literal field, give every variant its own value. States and events are both unions of literal-typed records.
type State =
| { kind: "placed"; orderId: string }
| { kind: "paid"; orderId: string; chargeId: string }
| { kind: "shipped"; orderId: string; tracking: string }
| { kind: "delivered"; orderId: string; deliveredAt: Date }
| { kind: "cancelled"; orderId: string; reason: string };
type Event =
| { kind: "pay"; chargeId: string }
| { kind: "ship"; tracking: string }
| { kind: "deliver"; deliveredAt: Date }
| { kind: "cancel"; reason: string };
Two things matter here. The first is that each state carries the data the state needs and nothing else. A paid order has a chargeId. A shipped order has a tracking number. A placed order has neither, because it has not been paid or shipped. The shape encodes the lifecycle. You cannot read tracking off a placed state because the field is not in the type.
The second is that states and events are deliberately separate unions. People conflate them, making one big OrderStatus union with cancel sitting next to delivered. That collapses two different ideas into one and you lose the part of the type system that does the work for you. A state is what the order is right now; an event is what just happened to it. The transition table is the relation between them, and you cannot write that table cleanly if the two sides are the same type.
The Transition Table
This is the entire schema of the machine. Two-dimensional, indexed by state kind and event kind, valued at the kind of the resulting state.
type StateKind = State["kind"];
type EventKind = Event["kind"];
type Transitions = {
placed: { pay: "paid"; cancel: "cancelled" };
paid: { ship: "shipped"; cancel: "cancelled" };
shipped: { deliver: "delivered" };
delivered: {};
cancelled: {};
};
The map is a Record<StateKind, Partial<Record<EventKind, StateKind>>>, but written explicitly so each cell can be a literal type. placed accepts pay and cancel. shipped accepts deliver, full stop. Once a parcel is in the carrier's hands the warehouse cancellation flow no longer applies. delivered accepts nothing. cancelled accepts nothing. The terminal states are empty objects.
That delivered: {} is the line that closes the bug from the hook. Any attempt to dispatch cancel on a delivered state will look up Transitions["delivered"]["cancel"], which is undefined, which the checker will surface as a type error before the dispatch call typechecks at all.
You can prove the table is well-formed with a constraint:
type _Check = Transitions extends Record<StateKind, Partial<Record<EventKind, StateKind>>>
? true
: never;
If a future edit drops a state kind from the keys, or adds a value that is not a known state, _Check resolves to never and the file refuses to compile. The constraint is a one-line audit you can keep in the file.
dispatch(state, event) with Extract Narrowing
The dispatch function is the entire runtime. It takes a state and an event, looks up the next state kind in the table, and constructs the next state. The interesting part is the signature.
type NextKind<S extends StateKind, E extends EventKind> =
E extends keyof Transitions[S] ? Transitions[S][E] : never;
function dispatch<S extends State, E extends Event>(
state: S,
event: E,
): Extract<State, { kind: NextKind<S["kind"], E["kind"]> }> {
const table = TRANSITIONS[state.kind] as Partial<Record<EventKind, StateKind>>;
const nextKind = table[event.kind];
if (!nextKind) {
throw new Error(`illegal: ${state.kind} -/-> ${event.kind}`);
}
return buildNext(state, event, nextKind) as never;
}
NextKind is the conditional that does the policing. For a given state literal type and event literal type, it asks the table whether that pair has an entry. If it does, the result is the kind of the next state. If it does not, the result is never, and Extract<State, { kind: never }> collapses the return type to never, which means the call site cannot use the result at all.
The Extract step is what makes the call ergonomic. Given dispatch(placedState, payEvent), the return type narrows to the paid arm of the State union, not the whole union. The caller does not need to re-narrow the result. Composition stays type-safe across multiple dispatches.
The runtime body is plain. TRANSITIONS is the value-level mirror of the Transitions type, frozen with as const. buildNext is a small per-state constructor that merges the event payload into the next state shape. Five short cases, one per state, no clever generics needed at runtime because the type-level work is already done.
const TRANSITIONS = {
placed: { pay: "paid", cancel: "cancelled" },
paid: { ship: "shipped", cancel: "cancelled" },
shipped: { deliver: "delivered" },
delivered: {},
cancelled: {},
} as const satisfies Transitions;
function buildNext(state: State, event: Event, nextKind: StateKind): State {
const orderId = state.orderId;
switch (nextKind) {
case "paid":
return { kind: "paid", orderId, chargeId: (event as Extract<Event, { kind: "pay" }>).chargeId };
case "shipped":
return { kind: "shipped", orderId, tracking: (event as Extract<Event, { kind: "ship" }>).tracking };
case "delivered":
return { kind: "delivered", orderId, deliveredAt: (event as Extract<Event, { kind: "deliver" }>).deliveredAt };
case "cancelled":
return { kind: "cancelled", orderId, reason: (event as Extract<Event, { kind: "cancel" }>).reason };
case "placed":
return { kind: "placed", orderId };
}
}
The satisfies Transitions line is the bridge between the type and the value. If TRANSITIONS and Transitions drift, this line goes red. One source of truth, expressed twice (once for the checker, once for the runtime), tied together so they cannot lie to each other.
Compiler Refuses Illegal (state, event) Pairs
This is where the post earns its title. The transition that started this article — delivered + cancel — is the line the checker now stops.
declare const delivered: Extract<State, { kind: "delivered" }>;
declare const cancel: Extract<Event, { kind: "cancel" }>;
const result = dispatch(delivered, cancel);
// ^? Extract<State, { kind: never }> = never
The return type is never. Any line that uses result will fail to typecheck because there is no field, no method, no operation defined on never. If the caller writes result.orderId, the checker says Property 'orderId' does not exist on type 'never'. The illegal transition is sealed at the call site.
A stricter signature can refuse the call itself, not just the result:
function dispatch<S extends State, E extends Event>(
state: S,
event: NextKind<S["kind"], E["kind"]> extends never ? never : E,
): Extract<State, { kind: NextKind<S["kind"], E["kind"]> }> {
// ...same body...
}
Now dispatch(delivered, cancel) fails on the second argument with Argument of type 'cancel' is not assignable to parameter of type 'never'. The error points at the offending argument on the offending line. The webhook handler that walked delivered to cancelled cannot be written. The slippers stay paid for.
This is the same never exhaustion trick assertNever uses on switch statements, lifted to the level of a transition table. The checker proves the call is reachable; if it can prove the result is never, it refuses to typecheck the call.
Side Effects: onEntry and onExit with Type-Safe Handlers
A real machine does work when it changes state. Send the receipt email when an order becomes paid. Hand the parcel to the carrier when it becomes shipped. Refund the charge when it becomes cancelled. The framework needs to call those side-effects, and the side-effects need to receive the right state shape.
The handler map is keyed by state kind. The handler for paid receives the paid state, which has chargeId. The handler for shipped receives the shipped state, which has tracking. Mixing them up is a type error.
type Handlers = {
[K in StateKind]?: {
onEntry?: (state: Extract<State, { kind: K }>) => void | Promise<void>;
onExit?: (state: Extract<State, { kind: K }>) => void | Promise<void>;
};
};
const handlers: Handlers = {
paid: {
onEntry: async (s) => {
// s is Extract<State, { kind: "paid" }>
await sendReceipt(s.orderId, s.chargeId);
},
},
shipped: {
onEntry: async (s) => {
await notifyCarrier(s.orderId, s.tracking);
},
},
cancelled: {
onEntry: async (s) => {
await refund(s.orderId, s.reason);
},
},
};
The mapped type does the heavy lifting. [K in StateKind]? says every state may have a handler, none are required. Inside the value, K is a literal — "paid", "shipped", "cancelled" — and Extract<State, { kind: K }> narrows the parameter to the corresponding state arm. If you typo s.tracking inside the paid handler, the checker catches it. If you wire up an entry handler under a state kind that does not exist, the key fails to match StateKind and the file goes red.
The dispatch loop integrates the handlers without any extra typing work:
async function step<S extends State, E extends Event>(
state: S,
event: E,
handlers: Handlers,
): Promise<Extract<State, { kind: NextKind<S["kind"], E["kind"]> }>> {
await handlers[state.kind]?.onExit?.(state as never);
const next = dispatch(state, event);
await handlers[next.kind]?.onEntry?.(next as never);
return next;
}
The as never on the handler invocations is the one place type-erasure leaks into the runtime. At the dynamic lookup point, the checker has lost the connection between state.kind and the corresponding Handlers[K]. The cast is sound because the table guarantees state.kind === K at that callsite, but the checker cannot follow it through the dictionary lookup. The cast is acceptable here because the public surface stays fully typed.
Hierarchical States: Where XState Earns Its Keep
The flat machine in this post handles the linear order lifecycle. It does not handle the case where a state has its own internal sub-states. shipped, for instance, has phases (in_transit, out_for_delivery, delivery_attempted), and the parent shipped should not transition to delivered until the sub-state reaches a terminal point. That is hierarchical state, and it is the boundary where rolling your own becomes worse than reaching for a library.
XState v5 is the reference for this kind of work. Its setup({ types: { ... }, actions: { ... } }).createMachine({ ... }) API generates types for events, context, guards, and actions. Hierarchical states (states: { shipped: { initial: "in_transit", states: { ... } } }), parallel regions, history pseudostates, and invoked actors are first-class. None of them are things you want to hand-roll above a few hundred lines.
For something smaller still, robot3 (~1KB minified, BSD-2-Clause, last published September 2025) gives you a finite-state-machine API close to the one in this post but with batteries: send/receive helpers, immer-style state updates, and a service abstraction. It will not give you XState's hierarchical states, but for flat machines like the order lifecycle here, robot3's API is the one to reach for if you would rather not maintain the engine yourself.
The pragmatic split: the 60-line table-driven machine is right when the state graph is flat and small, the team owns the file, and the type-checker doing the policing is the reason you wrote it in the first place. XState is right when the graph has nesting, parallelism, or actor invocation. Robot3 is right when you want a small library API for flat graphs. Spending an afternoon on hierarchical states by hand will produce a worse XState in two days and a worse-still version in a week.
What the Engine Buys You
This is the part of TypeScript where the type system stops being a documentation layer and starts being a checked specification. The transition table is data, and the checker is the auditor that runs on every save. You did not need a runtime library to do that. You needed Extract, conditional types, mapped types, and the one literal field on every variant that lets the checker walk the union.
The next state machine you write is probably one of: an order, a payment, a document review, an onboarding flow. Start with the table. Type the states and the events, and let the compiler tell you which transitions you forgot. The day a colleague tries to ship delivered + cancel, the file goes red.
The Whole Engine
For copy-paste, here is the whole engine in one file. Sixty lines, no dependency, the compiler does the policing.
type State =
| { kind: "placed"; orderId: string }
| { kind: "paid"; orderId: string; chargeId: string }
| { kind: "shipped"; orderId: string; tracking: string }
| { kind: "delivered"; orderId: string; deliveredAt: Date }
| { kind: "cancelled"; orderId: string; reason: string };
type Event =
| { kind: "pay"; chargeId: string }
| { kind: "ship"; tracking: string }
| { kind: "deliver"; deliveredAt: Date }
| { kind: "cancel"; reason: string };
type StateKind = State["kind"];
type EventKind = Event["kind"];
type Transitions = {
placed: { pay: "paid"; cancel: "cancelled" };
paid: { ship: "shipped"; cancel: "cancelled" };
shipped: { deliver: "delivered" };
delivered: {};
cancelled: {};
};
const TRANSITIONS = {
placed: { pay: "paid", cancel: "cancelled" },
paid: { ship: "shipped", cancel: "cancelled" },
shipped: { deliver: "delivered" },
delivered: {},
cancelled: {},
} as const satisfies Transitions;
type NextKind<S extends StateKind, E extends EventKind> =
E extends keyof Transitions[S] ? Transitions[S][E] : never;
type EventOf<K extends EventKind> = Extract<Event, { kind: K }>;
function buildNext(state: State, event: Event, nextKind: StateKind): State {
const orderId = state.orderId;
switch (nextKind) {
case "paid":
return { kind: "paid", orderId, chargeId: (event as EventOf<"pay">).chargeId };
case "shipped":
return { kind: "shipped", orderId, tracking: (event as EventOf<"ship">).tracking };
case "delivered":
return { kind: "delivered", orderId, deliveredAt: (event as EventOf<"deliver">).deliveredAt };
case "cancelled":
return { kind: "cancelled", orderId, reason: (event as EventOf<"cancel">).reason };
case "placed":
return { kind: "placed", orderId };
}
}
function dispatch<S extends State, E extends Event>(
state: S,
event: NextKind<S["kind"], E["kind"]> extends never ? never : E,
): Extract<State, { kind: NextKind<S["kind"], E["kind"]> }> {
const table = TRANSITIONS[state.kind] as Partial<Record<EventKind, StateKind>>;
const nextKind = table[(event as Event).kind];
if (!nextKind) throw new Error(`illegal: ${state.kind} -/-> ${(event as Event).kind}`);
return buildNext(state, event as Event, nextKind) as never;
}
That is sixty lines of TypeScript, top to bottom. Drop it in a file, import dispatch, and the compiler will refuse delivered + cancel before the test runner starts up.
If this was useful
The transition-table pattern is a small piece of what The TypeScript Type System covers in depth. The book builds from literal types and never exhaustion through mapped types, conditional types, Extract and infer, branded types, and the kind of recursive type machinery that makes XState's setup API even possible to write. Hierarchical state, the Handlers mapped type from this post, and the NextKind conditional are exactly the territory the deep-dive volume spends its pages on.
If you are coming from JVM languages, the discriminated-union plus exhaustive-table pattern is the TypeScript answer to Kotlin's sealed-class hierarchies and when exhaustiveness — Kotlin and Java to TypeScript makes that bridge. If you are coming from PHP 8+, PHP to TypeScript covers the same ground from the other side. If you are shipping TS at work, TypeScript in Production covers the build, monorepo, and dual-publish concerns the type system itself does not touch.
The five-book set:
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
- The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471
All five books ship in ebook, paperback, and hardcover.

Top comments (0)