Typed Events and 43 State Machines
What if every custom event in your SPA were compile-time checked — the name, the payload, the emitter, and every listener — and the build failed the moment any of them drifted?
The Problem
Single-page applications communicate between modules through events. In the browser, that means window.dispatchEvent(new CustomEvent('app-ready')) on one side and window.addEventListener('app-ready', handler) on the other. The contract between emitter and listener is a string. No tool verifies that the string matches. No type checks the payload. No scanner confirms that a listener exists for every emitted event. When someone types 'app-redy' instead of 'app-ready', the result is silence — no error, no warning, just a feature that stops working and a developer who spends an hour in the debugger.
At the scale of a single event this is manageable. At the scale of this site — 43 finite state machines, 9 custom events, 10 adapter nodes, 27 composition edges — it is a liability. Every refactoring session risks introducing a phantom event that no one listens to, or a listener that waits for an event no one dispatches.
This series documents the system that eliminated that risk.
The Solution Stack
Four layers, each reinforcing the others:
EventDef<N, D>andEventBus<TEmits, TListens>— phantom types that make the compiler reject mismatched events, missing payloads, and unauthorized emissions at compile time.@FiniteStateMachinedecorator — a metadata carrier on companion classes that declares states, transitions, emits, listens, guards, and feature links. Zero runtime cost. Read by build-time tooling via the TypeScript Compiler API.Event topology scanner — an AST-based tool that walks every source file, extracts every
dispatchEventandaddEventListenercall, cross-references them against the decorator metadata, and fails the commit if anything drifts.Three-phase build pipeline — (1) infer transitions from JSDoc diagrams,
canTransitionswitches, and AST walking; (2) extract the full state graph via the TypeScript Compiler API; (3) render an interactive SVG explorer with elkjs layout and content-hash caching.

What Changed
| Metric | Before | After |
|---|---|---|
| State machines | ~15 (ad hoc) | 43 (decorated) |
| Custom events | untracked | 9 (typed, scanned) |
| Event contract | string | EventDef<N, D> phantom type |
| Bus typing | any |
EventBus<TEmits, TListens> |
| Emitter/listener verification | manual | AST-based topology scanner |
| Drift detection | none | commit gate (non-zero exit) |
| Transition inference | manual | JSDoc + canTransition + AST |
| State graph | undocumented | state-machines.json (26KB) |
| Interactive explorer | none | elkjs SVG with popovers |
| Feature traceability | partial | 40/43 machines linked to requirements |
| Composition graph | implicit | fsm-composition.json (D3-ready) |
Part I: The Problem with dispatchEvent
Why window.dispatchEvent(new Event('app-ready')) is an invisible contract that no tool can verify. The scale of the problem in a 43-machine SPA. A concrete phantom event. The drift story. A preview of the solution.
Part II: EventDef and the Typed EventBus
Phantom type parameters. EventDef<N, D> — three lines that prevent an entire class of bugs. defineEvent() as an open/closed factory. EventBus<TEmits, TListens> — the port interface with variadic emit signature. createEventBus() — the adapter wrapping any EventTarget. Concrete bus types. Four compile errors the type system catches.
Part III: @FiniteStateMachine — The Decorator That Becomes a Database
The companion class pattern. FsmDescriptor — the full schema. FsmTransitionEntry and FsmFeatureLink. as const for literal preservation. FSM_SYMBOL at zero runtime cost. getFsmDescriptor() for tests. Concrete decorator examples. How the build-time extractor reads decorators.
Part IV: A Tour of 43 Pure Machines
Deep dives into representative machines: AccentPalette (toggle), CopyFeedback (timer), TerminalDots (compound boolean), PageLoad (generation counter), AppReadiness (barrier), SpaNav (navigation classification), TocBreadcrumb (typewriter), ScrollSpy (mouse vs scroll). Each with states, transitions, guards, bus type, and code.
Part V: Coordinators and the Adapter Pattern
TourCoordinator, ThemeCoordinator, ExplorerCoordinator — pure coordination without DOM. The adapter pattern: app-shared.ts as the DOM boundary. Concrete ScrollSpy adapter walkthrough. The delegated phantom pattern. The 10 adapter nodes.
Part VI: The Event Topology — Nine Events, One Living Map
The 9 custom events cataloged. Event flow walkthroughs: cold boot, SPA navigation, sidebar mask. Module-private vs shared events. The phantom problem: why pure FSMs cannot dispatch events and how adapters bridge the gap.
Part VII: Drift Detection — The Topology Scanner
AST-based scanning. The DOM event exclusion set. Four report sections: pivot table, drift detection, adapter sites, semantic gaps. The commit gate. Delegated phantoms. The four invariants. When the scanner caught a real bug.
Part VIII: Phase 1 — Inferring Transitions from Source Code
Three inference strategies: JSDoc diagrams with unicode arrows, canTransition switch analysis, AST walking for state = 'literal' assignments. Priority selection. patchDecorator — rewriting decorators with inferred transitions. Before/after.
Part IX: Phase 2 — Extraction via TypeScript Compiler API
MachineNode, AdapterNode, GraphEdge. Walking the AST for decorator metadata. as const unwrapping. Edge building: imports, event, composes. The output: state-machines.json — 43 machines, 10 adapters, 27 edges.
Part X: Phase 3 — SVG Rendering, Cache, and the Interactive Explorer
elkjs layout. The LayoutEngine DI seam. RenderCache — content-hash caching with pure decision logic. The explorer UI: popovers, pan/zoom, per-machine statecharts. The full build:state-graph pipeline.
Part XI: Feature Traceability and the Quality Gate Chain
FsmFeatureLink { id, ac }. The requirements directory. Orphan detection. The compliance scanner integration. The full quality gate chain. Testing typed events and pure machines: unit, property-based, E2E.
Part XII: Advanced Patterns and Retrospective
Compound state. Barrier synchronization. Generation counters. Immutable reducers. Panel events. Timer injection. Comparison with xstate. What went right, what was over-engineered, and what the 44th machine would look like.
Prerequisites
This series builds on two prior series:
- The State Machine Pattern in TypeScript — the factory function, closure state, callback injection, and
canTransitionpattern that every machine in this series follows. - Closing the Loop: From Manual Bindings to AST-Inferred Traceability — the AST scanner,
BindingsManifest, and compliance scanner that the feature traceability chapter (Part XI) extends.
For the C# origin of the requirement traceability approach, see Requirements as Code in TypeScript.