Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part 09 — FSM typed dispatch: state unions and Extract<Transition, { via: E }>['to']

The previous article walked the entity-scaffolding quadruple and noted, in passing, that stage 60's validator imports an OrderState literal-union from a file emitted by stage 40. This article opens that file. Two generators carry the FSM family: stage 40 emits the state literal-union and the transition graph as a discriminated union, stage 70 emits a typed dispatcher whose return type narrows the destination state from the verb at the call site.

The argument of this article is type-level. dispatch${Prefix}<E extends ${Prefix}Event>(state, event, payload: ${Prefix}EventOf<E>): ${Prefix}TargetOf<E> | ${Prefix}State is one line of TypeScript that does an unusual amount of work: the type parameter E is constrained to the event union, the payload parameter is narrowed to the payload type of that specific event by Extract<Transition, { via: E }>['payload'], and the return type is narrowed to the destination state by Extract<Transition, { via: E }>['to']. The compiler picks up all three narrowings at the call site. This is the kind of type-level trick that pays for the whole multi-stage codegen apparatus: the user writes one @Transition decorator, the example emits both the data and the type-level machinery, and the consumer gets compile-time guarantees on the dispatch.

The two generators total roughly 150 lines. After this article the reader has seen exactly how a Roslyn-style codegen pipeline produces type-level emissions, not just runtime data structures.

Stage 40 — FsmStatesGenerator

Per @StateMachine, the generator emits a <ClassName>.states.generated.ts module containing the literal unions, the transition graph, and the conditional-type helpers. Pure scan, no virtFS reads, emits at iteration 0. The render function is in 40-fsm-states.ts:41-66:

return [
  `export type ${prefix}State = ${stateUnion};`,
  `export type ${prefix}Initial = '${initial}';`,
  `export type ${prefix}Terminal = ${terminalUnion};`,
  ``,
  `export type ${prefix}Transition =${transitionUnion};`,
  ``,
  `export type ${prefix}Event = ${prefix}Transition extends never ? never : ${prefix}Transition['via'];`,
  `export type ${prefix}EventOf<E extends ${prefix}Event> = Extract<${prefix}Transition, { via: E }>['payload'];`,
  `export type ${prefix}TargetOf<E extends ${prefix}Event> = Extract<${prefix}Transition, { via: E }>['to'];`,
  ``,
].join('\n');

The output for OrderFsm (whose entity: 'order' produces the Order prefix):

export type OrderState = 'pending' | 'paid' | 'shipped';
export type OrderInitial = 'pending';
export type OrderTerminal = 'shipped';

export type OrderTransition =
  | { from: 'paid'; to: 'shipped'; via: 'ship'; payload: Record<string, never> }
  | { from: 'pending'; to: 'paid'; via: 'pay'; payload: { amount: number } };

export type OrderEvent = OrderTransition extends never ? never : OrderTransition['via'];
export type OrderEventOf<E extends OrderEvent> = Extract<OrderTransition, { via: E }>['payload'];
export type OrderTargetOf<E extends OrderEvent> = Extract<OrderTransition, { via: E }>['to'];

Five exported types, each doing distinct type-level work. They are the foundation that stage 70's dispatcher will compose.

${Prefix}State — the literal union

OrderState = 'pending' | 'paid' | 'shipped' is a literal-union type. Every state declared with @State({ name }) contributes one literal to the union. The order of literals in the source comes from scanFsms — sorted alphabetically by name in scan-fsms.ts:73 — so the union is deterministic across runs.

Consumers of this type can declare variables typed OrderState and the compiler will reject any string that isn't one of the three literals. A naive status: string field on Order becomes a status: OrderState field on OrderValidated (Part 08); the compiler rejects order.status = 'cancelled' because 'cancelled' is not in the union.

${Prefix}Initial and ${Prefix}Terminal — singleton and union

OrderInitial = 'pending' is a singleton literal type — the FSM has exactly one initial state, so the type is exactly one literal. OrderTerminal = 'shipped' is a union of literals — the FSM can have multiple terminal states, so the type is the union of those literals (one in this example, hypothetically more in a different FSM). Both types are useful at consumer-site for things like function start(): OrderInitial (returns the initial state by type) or function isTerminal(s: OrderState): s is OrderTerminal (a type predicate that narrows).

${Prefix}Transition — the discriminated union

This is the load-bearing emission. Each @Transition({ from, to, via }) becomes one member of a union, and the method's parameter signature becomes the payload field of that member:

export type OrderTransition =
  | { from: 'paid'; to: 'shipped'; via: 'ship'; payload: Record<string, never> }
  | { from: 'pending'; to: 'paid'; via: 'pay'; payload: { amount: number } };

The discriminator is all three of from, to, via — TypeScript can narrow a OrderTransition value by any of them, but the dispatcher will narrow specifically by via. The payload field is what makes the dispatcher's payload parameter type-safe.

The empty-parameter case is handled by the Record<string, never> shape (40-fsm-states.ts:71). A method ship(): void has zero parameters; the payload is structurally {}. Record<string, never> is the canonical TS spelling of "an object with no permitted keys" — strictly stronger than {} (which is "any non-null value") and exactly right for the no-payload case. A consumer who calls dispatch(state, 'ship', {}) typechecks; a consumer who calls dispatch(state, 'ship', { extra: true }) fails to typecheck because extra is not never.

${Prefix}Event — conditional projection

OrderEvent = OrderTransition extends never ? never : OrderTransition['via'] is a conditional type that handles the empty-FSM case explicitly. If OrderTransition is never (the FSM has no transitions), OrderEvent is never; otherwise it is the via projection of the transition union, which yields 'pay' | 'ship'. The conditional avoids a known TS quirk: never['via'] is never, but the projection happens to also be never, and TypeScript would accept that without warning. The explicit conditional makes the intent visible: if there are no transitions, there are no events.

A small but real TS subtlety hides here. OrderTransition['via'] distributes over the union — TypeScript picks 'ship' | 'pay' — exactly the union of via literals. The pattern Union['key'] is the canonical way to project a single discriminator from a discriminated union.

${Prefix}EventOf<E> and ${Prefix}TargetOf<E>Extract over the discriminator

These are the conditional-type helpers the dispatcher will compose:

export type OrderEventOf<E extends OrderEvent> = Extract<OrderTransition, { via: E }>['payload'];
export type OrderTargetOf<E extends OrderEvent> = Extract<OrderTransition, { via: E }>['to'];

Extract<OrderTransition, { via: E }> picks out the union members whose via matches E. For E = 'pay' it returns { from: 'pending'; to: 'paid'; via: 'pay'; payload: { amount: number } }. The ['payload'] projection then yields { amount: number }; the ['to'] projection yields 'paid'.

Both helpers are generic. The consumer will instantiate them with a concrete E at the call site, and TypeScript will resolve the Extract and the projection at type-check time. The dispatcher uses both helpers in its parameter and return types; the dispatch becomes type-safe by composition.

Stage 70 — FsmDispatcherGenerator

Per @StateMachine, the generator emits a <ClassName>.dispatcher.generated.ts module with a typed dispatch function. The generator reads the partner states module from virtFS — same family, same iteration — and skips its work if the states module hasn't yet landed. The execute method (70-fsm-dispatcher.ts:19-39) is the same shape as stage 50's mapper: scan, check virtFS, emit:

async execute(ctx: GenerationContext): Promise<GenerationResult> {
  const diagnostics: Diagnostic[] = [];
  let emittedSomething = false;

  for (const fsm of scanFsms(ctx.project)) {
    const statesRel = `${fsm.className}.states.generated.ts`;
    if (!ctx.virtFs.has(statesRel)) continue;

    const body = renderDispatcher(fsm);
    /* ... addSource as usual ... */
  }
  return { emittedSomething, diagnostics };
}

The output for OrderFsm:

import type {
  OrderState,
  OrderEvent,
  OrderEventOf,
  OrderTargetOf,
} from './OrderFsm.states.generated.js';

const OrderTransitionTable: ReadonlyArray<{
  readonly from: OrderState;
  readonly to: OrderState;
  readonly via: OrderEvent
}> = [
  { from: 'paid', to: 'shipped', via: 'ship' },
  { from: 'pending', to: 'paid', via: 'pay' },
];

export function dispatchOrder<E extends OrderEvent>(
  state: OrderState,
  event: E,
  _payload: OrderEventOf<E>,
): OrderTargetOf<E> | OrderState {
  for (const t of OrderTransitionTable) {
    if (t.from === state && t.via === event) {
      return t.to as OrderTargetOf<E>;
    }
  }
  return state;
}

Three observations.

The first is the runtime component: OrderTransitionTable is a real array, present at runtime, used to look up the destination state at dispatch time. The dispatcher is not pure types — it is a runtime function that consumes the table. The pattern is therefore codegen-as-data-driven-runtime: the generator emits both the table (data) and the function (logic), and the consumer's runtime cost is one array lookup per dispatch.

The second is the type signature. Read carefully:

export function dispatchOrder<E extends OrderEvent>(
  state: OrderState,
  event: E,
  _payload: OrderEventOf<E>,
): OrderTargetOf<E> | OrderState

The type parameter E is bound at the call site by the value of event. If the consumer writes dispatchOrder(s, 'pay', { amount: 100 }), TypeScript infers E = 'pay'. With E = 'pay':

  • _payload: OrderEventOf<'pay'>_payload: { amount: number }. The compiler enforces that the payload value matches the transition's payload type. A call dispatchOrder(s, 'pay', {}) fails to typecheck (missing amount); a call dispatchOrder(s, 'pay', { amount: '100' }) fails to typecheck (wrong payload type).
  • The return type OrderTargetOf<'pay'> | OrderState resolves to 'paid' | OrderState. The 'paid' is the narrowed destination state — the type system promises that if the transition fires, the result is 'paid'. The | OrderState covers the fallback case where the transition does not match the current state and the dispatcher returns the original state unchanged.

The third is the as OrderTargetOf<E> cast inside the loop body. The dispatcher's runtime check t.from === state && t.via === event cannot be expressed at the type level — TypeScript does not evaluate string-equality predicates at type-check time — so the dispatcher uses a cast to assert that t.to (typed as OrderState at runtime) is actually OrderTargetOf<E> (the narrower destination type implied by the matched transition). The cast is sound because the OrderTransitionTable was built from the same @Transition decorators that produced the discriminated union; if the consumer added a transition to the table that wasn't in the union, the type-checker would reject it elsewhere. The cast is a documented escape hatch, not a bug.

What the dispatch composition gives the consumer

A consumer's call site:

const next = dispatchOrder('pending', 'pay', { amount: 100 });
//   ^ inferred type: 'paid' | OrderState

The compiler narrows next to 'paid' | OrderState. The consumer can immediately discriminate:

if (next === 'paid') {
  // typed as 'paid' here
  await chargeCard(/* ... */);
}

A consumer who tries to dispatch with a wrong-typed payload:

const next = dispatchOrder('pending', 'pay', {});
// Error: Property 'amount' is missing in type '{}' but required in type '{ amount: number }'.

A consumer who tries to dispatch with a non-existent event:

const next = dispatchOrder('pending', 'cancel', {});
// Error: Argument of type '"cancel"' is not assignable to parameter of type 'OrderEvent'.

All three errors are caught at compile time, before the code is run. The consumer never wrote any of the OrderEvent, OrderEventOf, OrderTargetOf types — the example emitted them once, the consumer benefits from them everywhere they call the dispatcher.

What the FSM family does not (yet) provide

Three properties a reader might expect, with notes on where they sit.

First, the dispatcher is not a runtime FSM. A runtime FSM would carry state, enforce that the from matches the current state at dispatch time (instead of returning state on no-match), provide subscriptions to state transitions, support guards and side-effects on transitions, and so on. The example deliberately stops at typed dispatch — given a state, an event, and a payload, return the next state. A consumer who wants a full runtime FSM can build one on top of the dispatcher; the codegen layer does not constrain that choice.

Second, the conditional types do not encode the from constraint. A consumer can call dispatchOrder('shipped', 'pay', { amount: 100 }), and the type-checker accepts it (the dispatch will fall through and return 'shipped' unchanged at runtime). A more ambitious type would constrain state: ${Prefix}SourceOf<E> so the compiler rejects an event that doesn't apply to the current state. The example does not do this because the resulting type is significantly more complex (a third conditional helper, a from projection per event), and the runtime fall-through is a perfectly valid behavior for a state machine that handles unexpected events. A future revision could opt in.

Third, the dispatcher does not validate the transition graph for reachability. A user could declare a paid state with no transition leading to it; the dispatcher would still emit, the FSM would be unreachable from the initial state, and no compile-time error would surface. Detecting unreachable states is graph analysis on the transition union, doable in principle, not done here. A future "FSM analyzer" generator could surface diagnostics for unreachable states, dead transitions, missing initial-state, and so on; the FSM dispatcher generator stops at typed dispatch.

The acceptance criteria

The Feature class for FSM typed dispatch is in requirements/features/fsm-typed-dispatch.feature.ts, backed by req-fsm-typed-dispatch.ts. The fit criteria include: state-union emitted matches the @State declarations; transition discriminated union members carry per-method payload types; dispatch${Prefix} typechecks at the call site only with valid event/payload pairs; dangling @Entity({lifecycle}) references that point to no FSM are surfaced as info diagnostics (the dangling-lifecycle case Part 08 walked).

The test file test/unit/fsm-typed-dispatch.test.ts is 101 lines and asserts each criterion against the canonical sandbox.

Bridge

Part 10 walks the module-family generators (stages 10 and 80) and closes the registry backward-edge loop — the property the example exists to demonstrate, named in Part 02 and used as the motivating example throughout Part 03. After Part 10 every stage of the pipeline has been walked and the three iterations convergence claim is no longer abstract.

The Feature for this article is FEAT-TSGEN-09 in assets/features.ts. Acceptance criteria: state-union generation explained; discriminated transition union explained; Extract<...,{via:E}>['to'] target inference explained; SGE-FSM-TYPED-DISPATCH acs anchored. Each section above maps to one of those ACs.

⬇ Download