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

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:

  1. EventDef<N, D> and EventBus<TEmits, TListens> — phantom types that make the compiler reject mismatched events, missing payloads, and unauthorized emissions at compile time.

  2. @FiniteStateMachine decorator — 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.

  3. Event topology scanner — an AST-based tool that walks every source file, extracts every dispatchEvent and addEventListener call, cross-references them against the decorator metadata, and fails the commit if anything drifts.

  4. Three-phase build pipeline — (1) infer transitions from JSDoc diagrams, canTransition switches, 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.

The typed event topology — 43 machines connected by 9 custom events, rendered by the interactive explorer
The typed event topology — 43 machines connected by 9 custom events, rendered by the interactive explorer

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:

For the C# origin of the requirement traceability approach, see Requirements as Code in TypeScript.

⬇ Download