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

@FiniteStateMachine — The Decorator That Becomes a Database

Part II built the typed event bus — a compile-time layer that prevents mismatched event names, wrong payloads, and unauthorized emissions. But the event bus is infrastructure. The real question is: what are the 43 machines that use it, what states do they have, what transitions do they follow, and how do they connect to each other through events?

That information needs to live somewhere. It needs to be readable by humans (for documentation), by the compiler (for type checking), by build-time tools (for the state graph), and by tests (for verification). It needs to be declared once and consumed many times.

The answer is a decorator: @FiniteStateMachine. It takes a descriptor object that lists states, events, transitions, emits, listens, guards, feature links, scope, and description. It attaches that descriptor to a companion class via a non-enumerable Symbol property. And that property becomes a database — a structured record that the TypeScript Compiler API reads at build time, that getFsmDescriptor() reads in tests, and that humans read in source code.

This part covers the full decorator system: the companion class pattern, every field of FsmDescriptor, the transition entry format, the feature link, why as const is critical, how FSM_SYMBOL achieves zero runtime cost, and how the build-time extractor (Part IX) reads it all.

The Companion Class Pattern

Here is a pattern you will see 43 times in this codebase:

@FiniteStateMachine({
  states: ['idle', 'loading', 'done'] as const,
  events: ['start', 'finish'] as const,
  transitions: [
    { from: 'idle', to: 'loading', on: 'start' },
    { from: 'loading', to: 'done', on: 'finish' },
  ] as const,
})
export class SomeFeatureFsm {}

The class has zero members. No properties. No methods. No constructor. It is empty. Why does it exist?

Because TypeScript decorators can only be applied to class declarations. You cannot decorate a function, a const, or an export default. The language grammar requires a class target. The factory function that actually creates the machine — createSomeFeature() — is a plain function, not a class. So the decorator cannot go there.

The companion class exists purely to carry the decorator. It is a metadata vessel — an empty hull onto which @FiniteStateMachine bolts the descriptor. At runtime, the class is never instantiated. No one ever calls new SomeFeatureFsm(). The class declaration is evaluated once (attaching the descriptor via Object.defineProperty), and then the class sits in memory as a function object with a hidden Symbol property.

Why Not Decorate the Factory Function?

The factory function is where the machine logic lives:

export function createSomeFeature(callbacks: SomeFeatureCallbacks): SomeFeatureMachine {
  let state: SomeFeatureState = 'idle';
  // ... pure logic ...
  return { getState, start, finish };
}

If TypeScript allowed function decorators, the @FiniteStateMachine decorator could go here and there would be no companion class. But TypeScript follows the TC39 decorators proposal (stage 3), which restricts decorators to classes, methods, accessors, and fields. Functions are excluded.

There are workarounds — wrapping the function in a class method, using higher-order functions — but they all add complexity without adding value. The companion class pattern is the simplest approach: one empty class, one decorator, zero ambiguity.

Naming Convention

Every companion class ends in Fsm:

  • PageLoadStateFsm — companion for createPageLoadState()
  • AppReadinessFsm — companion for createAppReadiness()
  • TerminalDotsStateFsm — companion for createTerminalDotsState()
  • EventBusFsm — companion for createEventBus()

The build-time extractor (Part IX) uses this convention: it walks the AST, finds every class whose name ends in Fsm, and checks for the @FiniteStateMachine decorator. The convention is not enforced by the type system — it is enforced by the naming pattern and the extractor's search logic.

Where the Companion Lives

The companion class is declared in the same file as the factory function, immediately before or after the factory. This keeps the metadata and the implementation in the same module:

// src/lib/page-load-state.ts

// ... type definitions, callbacks interface, machine interface ...

@FiniteStateMachine({ /* descriptor */ })
export class PageLoadStateFsm {}

export function createPageLoadState(callbacks: PageLoadCallbacks): PageLoadMachine {
  // ... implementation ...
}

One file, one machine, one companion. The descriptor at the top of the file serves as both documentation and machine-readable metadata.

FsmDescriptor — The Full Schema

The FsmDescriptor interface defines every field the decorator accepts. Here is the complete source of src/lib/finite-state-machine.ts:

/**
 * @FiniteStateMachine — decorator for explicit FSM metadata.
 * Apply to a companion class next to a factory function to declare the
 * machine's states and events explicitly.
 */

export interface FsmTransitionEntry {
  readonly from: string;
  readonly to:   string;
  readonly on:   string;
  readonly when?: string;
}

export interface FsmFeatureLink {
  readonly id: string;
  readonly ac: string;
}

export interface FsmDescriptor<
  TStates extends readonly string[],
  TEvents extends readonly string[] = readonly never[],
> {
  readonly states: TStates;
  readonly events?: TEvents;
  readonly transitions?: ReadonlyArray<FsmTransitionEntry>;
  readonly emits?: readonly string[];
  readonly listens?: readonly string[];
  readonly guards?: readonly string[];
  readonly feature?: FsmFeatureLink;
  readonly description?: string;
  readonly scope?: 'singleton' | 'scoped' | 'transient';
}

export const FSM_SYMBOL: unique symbol = Symbol('FiniteStateMachine');

export function FiniteStateMachine<
  TStates extends readonly string[],
  TEvents extends readonly string[] = readonly never[],
>(descriptor: FsmDescriptor<TStates, TEvents>) {
  return function <T extends Function>(target: T): T {
    Object.defineProperty(target, FSM_SYMBOL, {
      value:        descriptor,
      enumerable:   false,
      writable:     false,
      configurable: false,
    });
    return target;
  };
}

export function getFsmDescriptor(
  cls: Function,
): FsmDescriptor<readonly string[], readonly string[]> | undefined {
  return (cls as unknown as Record<symbol, unknown>)[FSM_SYMBOL] as
    | FsmDescriptor<readonly string[], readonly string[]>
    | undefined;
}

That is the entire file — 63 lines of source. Two interfaces, one const, one decorator function, one accessor function. Everything the 43-machine architecture hangs on.

Let us walk through each field of FsmDescriptor.

states: TStates

A readonly tuple of string literals naming every state the machine can be in. This is the only required field.

states: ['idle', 'loading', 'rendering', 'postProcessing', 'done', 'error'] as const

The type parameter TStates extends readonly string[] preserves the literal tuple type when as const is used. Without as const, TypeScript widens the array to string[] and the individual state names are lost. With as const, the type is readonly ['idle', 'loading', 'rendering', 'postProcessing', 'done', 'error'] — every state name is a string literal.

events?: TEvents

A readonly tuple of string literals naming the events (triggers/actions) that cause state transitions. Optional — some machines define transitions without explicit event names.

events: ['startLoad', 'markRendering', 'markPostProcessing', 'markDone', 'markError'] as const

These are internal machine events — method calls on the machine interface. They are not custom DOM events. The distinction matters: events names the machine's API (what the adapter calls), while emits and listens name the bus events (what flows between machines).

transitions?: ReadonlyArray<FsmTransitionEntry>

An array of { from, to, on, when? } entries describing the state graph. Optional — the build pipeline can infer transitions from source code (Part VIII), but explicit transitions in the decorator are preferred because they serve as documentation and are faster to extract.

transitions: [
  { from: 'idle', to: 'loading', on: 'startLoad' },
  { from: 'loading', to: 'rendering', on: 'markRendering' },
  { from: '*', to: 'error', on: 'markError' },
] as const

The transition format is covered in detail in the next section.

emits?: readonly string[]

The custom DOM events this machine (via its adapter) emits through the event bus:

emits: ['app-ready', 'toc-headings-rendered'] as const

These names must match the name field of EventDef constants defined in events.ts or co-located with the machine. The topology scanner (Part VII) verifies that every name in emits corresponds to an actual bus.emit() call in the adapter.

listens?: readonly string[]

The custom DOM events this machine (via its adapter) listens to:

listens: ['sidebar-mask-change'] as const

Same verification as emits — the topology scanner checks that every listens entry has a corresponding bus.on() call.

guards?: readonly string[]

Guard conditions that appear in when fields of transitions:

guards: ['staleGeneration'] as const

Guards are predicate functions that determine whether a transition should fire. A transition with when: 'allReceived' fires only if the allReceived guard returns true. The guard names in this field document what predicates exist — the actual guard logic lives in the factory function.

A link to the Feature abstract class and acceptance criterion that this machine implements:

feature: { id: 'PAGE-LOAD', ac: 'fullLifecycle' }

Covered in detail in the "FsmFeatureLink" section below.

description?: string

A human-readable one-liner describing the machine's purpose:

description: 'Typed event bus — mediates all cross-FSM custom events with compile-time safety.'

Used in the interactive explorer's tooltips and in generated documentation.

scope?: 'singleton' | 'scoped' | 'transient'

The machine's lifecycle scope:

  • singleton — one instance for the entire application lifetime. Examples: EventBusFsm, AppReadinessFsm.
  • scoped — one instance per route/page. Created on navigation, destroyed on route change. Most machines are scoped.
  • transient — created on demand and discarded. Example: CopyFeedbackStateFsm (one per copy-button click, short-lived).

This field is informational — the runtime does not read it. But the interactive explorer uses it to color-code machines, and the topology scanner uses it to validate lifecycle assumptions (a singleton should not listen to a transient's events).

Diagram
The FsmDescriptor schema — nine fields, one required, eight optional. Each field feeds a different stage of the toolchain.

FsmTransitionEntry — The State Graph

Each transition entry is a four-field object:

export interface FsmTransitionEntry {
  readonly from: string;
  readonly to:   string;
  readonly on:   string;
  readonly when?: string;
}
  • from — the source state. Must be one of the strings in states, or '*' for a wildcard.
  • to — the target state. Must be one of the strings in states.
  • on — the event (trigger) that causes the transition. Must be one of the strings in events.
  • when — an optional guard condition. If present, the transition fires only when this predicate is true. Must be one of the strings in guards.

Reading a Transition

{ from: 'loading', to: 'rendering', on: 'markRendering' }

Reads as: "When the machine is in the loading state and markRendering is called, transition to rendering."

{ from: 'pending', to: 'ready', on: 'navPanePainted', when: 'allReceived' }

Reads as: "When the machine is in the pending state and navPanePainted is called, transition to ready — but only if the allReceived guard is true."

The * Wildcard

The from field accepts '*' to mean "any state":

{ from: '*', to: 'error', on: 'markError' }

This means: "From any state, when markError is called, transition to error." The wildcard is used for error states — an error can occur regardless of the current state. The build-time extractor expands '*' into one transition per state when building the state graph.

Why Transitions Are Optional

The transitions field is optional because some machines are better described by their states and events alone. A machine with two states and one event has an obvious transition — declaring it explicitly adds little value. And for machines where the transition logic is complex (conditional on runtime data, not just guard predicates), the transitions array would be misleading — it would promise determinism that the implementation does not deliver.

That said, explicit transitions are recommended for any machine with more than two states. They serve as documentation, they feed the interactive explorer's statechart rendering, and they allow the build pipeline to skip the inference phase (Part VIII) for that machine — which is faster and more reliable.

Why Transitions Are Not Typed Against States

The FsmTransitionEntry interface uses string for from, to, and on — not the generic TStates[number] or TEvents[number]. This is a pragmatic choice. TypeScript's type system can express "one of the literal union" via TStates[number], but the resulting error messages when a transition references a nonexistent state are cryptic and unhelpful. A plain string with runtime validation (in the extractor) produces clearer error reports.

The tradeoff: a typo in a transition entry — { from: 'idle', to: 'laoding', on: 'start' } — is not caught at compile time. It is caught at build time when the extractor validates that every from and to value appears in states. The feedback loop is a npm run build away, not an IDE red squiggly. For 43 machines, this has been acceptable.

export interface FsmFeatureLink {
  readonly id: string;
  readonly ac: string;
}

Every machine should implement a feature — a user-visible capability described in a Feature abstract class. The FsmFeatureLink connects the machine to that feature:

feature: { id: 'PAGE-LOAD', ac: 'fullLifecycle' }
  • id — the feature identifier. Matches a Feature_* abstract class in the requirements directory (e.g., Feature_PAGE_LOAD).
  • ac — the acceptance criterion within that feature. A single feature may have multiple acceptance criteria; the ac names which one this machine addresses.

A machine without a feature link is a machine without a business justification. It exists in the codebase but no one can explain what user-facing behavior it supports. The compliance scanner (Part XI) flags machines with missing feature links as orphans — code that may be dead, over-engineered, or simply undocumented.

As of this writing, 40 of 43 machines have feature links. The three without them are infrastructure machines (EventBusFsm, HotReloadActionsFsm, DevWatcherFsm) — machines that support developer tooling rather than user features. They are exempt from the compliance requirement by convention, not by accident.

The compliance scanner walks every decorated class, reads the feature field, and builds a coverage matrix:

Feature_PAGE_LOAD
  AC: fullLifecycle     → PageLoadStateFsm     ✓
  AC: errorRecovery     → (no machine)          ✗  ← gap

Feature_TERMINAL_DOTS
  AC: maximizeEntersFocusMode → TerminalDotsStateFsm ✓
  AC: closeIsTerminal         → TerminalDotsStateFsm ✓

A missing link () is a requirements gap — either the acceptance criterion needs a machine, or the criterion itself is wrong. Either way, it surfaces during the build, not after a user reports a broken feature.

as const — Why Literal Preservation Matters

Consider these two declarations:

// Without as const
states: ['idle', 'loading', 'done']
// TypeScript infers: string[]

// With as const
states: ['idle', 'loading', 'done'] as const
// TypeScript infers: readonly ['idle', 'loading', 'done']

The difference is critical. Without as const, TypeScript widens the array to string[] — the individual state names are lost. The compiler knows the array contains strings, but not which strings. With as const, the type is a readonly tuple of string literals — the compiler knows the exact values.

Why the Build-Time Extractor Depends on Literals

The build-time extractor (Part IX) walks the TypeScript AST and reads the decorator's argument object. When it encounters:

states: ['idle', 'loading', 'done'] as const

The AST contains an AsExpression wrapping an ArrayLiteralExpression with three StringLiteral nodes. The extractor reads each StringLiteral and gets 'idle', 'loading', 'done'. This is reliable because the values are compile-time constants — they cannot be computed, imported from a variable, or constructed at runtime.

Without as const, the array might contain:

const IDLE = 'idle';
states: [IDLE, 'loading', 'done']

Now the first element is an Identifier node, not a StringLiteral. The extractor would need to resolve the identifier to its declaration, evaluate the initializer, handle re-assignments, and deal with imports from other modules. This is a full constant-folding problem — solvable, but fragile and slow.

The as const convention eliminates this complexity. Every value in the descriptor is a literal, and the extractor reads literals directly from the AST without resolution. The convention is documented, enforced by code review, and validated by the extractor (which warns on non-literal values).

The readonly Propagation

as const makes every level of the object readonly:

// This:
{
  states: ['idle', 'loading'] as const,
  transitions: [
    { from: 'idle', to: 'loading', on: 'start' },
  ] as const,
} as const

// Becomes this type:
{
  readonly states: readonly ['idle', 'loading'];
  readonly transitions: readonly [
    { readonly from: 'idle'; readonly to: 'loading'; readonly on: 'start' }
  ];
}

The FsmDescriptor interface is designed to accept this: states is TStates extends readonly string[], transitions is ReadonlyArray<FsmTransitionEntry>, and FsmTransitionEntry has readonly fields. The types align — as const input fits readonly parameters.

FSM_SYMBOL — Zero Runtime Cost

export const FSM_SYMBOL: unique symbol = Symbol('FiniteStateMachine');

The decorator attaches the descriptor to the class using this Symbol as the property key:

Object.defineProperty(target, FSM_SYMBOL, {
  value:        descriptor,
  enumerable:   false,
  writable:     false,
  configurable: false,
});

Three design choices are embedded in these five lines.

Why a Symbol?

A Symbol key cannot collide with any string key on the class. If the descriptor were stored as target.__fsmDescriptor, it could collide with a user-defined property named __fsmDescriptor. Symbols are guaranteed unique — Symbol('FiniteStateMachine') !== Symbol('FiniteStateMachine'). The unique symbol type in TypeScript makes this even stronger: the compiler treats FSM_SYMBOL as a distinct type, not just symbol.

Why Not Enumerable?

enumerable: false means the descriptor does not show up in Object.keys(), for...in loops, or JSON.stringify(). The descriptor is invisible to code that iterates over the class's properties. This prevents accidental serialization of metadata and keeps the class's public surface clean.

Code that needs the descriptor uses getFsmDescriptor(), which explicitly reads the Symbol property. Code that does not know about FSM metadata never sees it.

Why Not a WeakMap?

An alternative to Symbol properties is a WeakMap<Function, FsmDescriptor>:

const fsmMetadata = new WeakMap<Function, FsmDescriptor<any, any>>();

function FiniteStateMachine(descriptor: FsmDescriptor<any, any>) {
  return function(target: Function) {
    fsmMetadata.set(target, descriptor);
    return target;
  };
}

This works and is arguably cleaner — no monkey-patching of the class object. But it has a practical problem: the WeakMap is module-scoped. If finite-state-machine.ts is bundled separately from the code that reads the metadata (e.g., the build-time extractor), the WeakMap is inaccessible. The Symbol property, by contrast, travels with the class object — any code that has a reference to the class can read its metadata, regardless of module boundaries.

For a build-time tool that imports the compiled JavaScript and inspects class objects, Symbol properties are more portable than module-scoped WeakMaps.

Why Not Writable or Configurable?

writable: false and configurable: false make the descriptor immutable after attachment. No code can overwrite or delete it. This is a defensive measure — the descriptor is the source of truth for the machine's metadata, and it should never change after the class is declared.

Diagram
The decorator pipeline — source code declares metadata, the decorator attaches it to a Symbol property, and the build-time extractor reads it into state-machines.json.

getFsmDescriptor() — Runtime Reflection for Tests

export function getFsmDescriptor(
  cls: Function,
): FsmDescriptor<readonly string[], readonly string[]> | undefined {
  return (cls as unknown as Record<symbol, unknown>)[FSM_SYMBOL] as
    | FsmDescriptor<readonly string[], readonly string[]>
    | undefined;
}

This function is the runtime counterpart of the build-time extractor. It takes a class (any Function) and returns the FsmDescriptor attached by the decorator, or undefined if the class is not decorated.

The Double Cast

The cls as unknown as Record<symbol, unknown> cast is necessary because TypeScript does not allow indexing a Function with a symbol key directly. The cast says: "Trust me, this function object may have Symbol-keyed properties." The alternative — declaring the FSM_SYMBOL property on a custom interface — would require every companion class to implement that interface, which defeats the purpose of the decorator pattern.

Use in Unit Tests

The primary consumer of getFsmDescriptor() is the test suite. Tests verify that the decorator metadata matches the machine's actual behavior:

import { describe, it, expect } from 'vitest';
import { getFsmDescriptor } from './finite-state-machine';
import { PageLoadStateFsm } from './page-load-state';

describe('PageLoadStateFsm decorator', () => {
  const desc = getFsmDescriptor(PageLoadStateFsm);

  it('is decorated', () => {
    expect(desc).toBeDefined();
  });

  it('declares six states', () => {
    expect(desc!.states).toEqual([
      'idle', 'loading', 'rendering', 'postProcessing', 'done', 'error',
    ]);
  });

  it('declares transitions from idle', () => {
    const fromIdle = desc!.transitions?.filter(t => t.from === 'idle');
    expect(fromIdle).toHaveLength(1);
    expect(fromIdle![0]).toEqual({ from: 'idle', to: 'loading', on: 'startLoad' });
  });

  it('has a feature link', () => {
    expect(desc!.feature).toEqual({ id: 'PAGE-LOAD', ac: 'fullLifecycle' });
  });
});

These tests serve two purposes:

  1. Documentation — the test file is a readable specification of what the decorator declares.
  2. Drift detection — if someone changes the decorator metadata without updating the tests (or vice versa), the test fails. This is a lightweight version of the build-time extractor's validation, but it runs in milliseconds via vitest instead of requiring a full build.

The Five-Element Template

Every finite state machine in this codebase follows the same five-element structure. Understanding this template is essential before looking at the decorator examples, because the decorator is one element of a larger pattern.

Element 1: The State Type

A string literal union defining all valid states:

export type PageLoadState = 'idle' | 'loading' | 'rendering' | 'postProcessing' | 'done' | 'error';

This type is used in the factory function's closure (let state: PageLoadState = 'idle') and in the machine interface's getState() return type.

Element 2: The Callbacks Interface

An object of functions that the adapter provides — the machine's only way to produce side effects:

export interface PageLoadCallbacks {
  onStateChange(from: PageLoadState, to: PageLoadState): void;
  onError(error: Error): void;
}

The machine calls callbacks.onStateChange(from, to) when it transitions. The adapter implements this callback to dispatch events, update the DOM, or log metrics. The machine itself never touches the DOM or the event bus — it is pure.

Element 3: The Machine Interface

The public API — what the adapter calls to drive the machine:

export interface PageLoadMachine {
  getState(): PageLoadState;
  startLoad(): void;
  markRendering(): void;
  markPostProcessing(): void;
  markDone(): void;
  markError(error: Error): void;
}

Every method maps to an event in the decorator's events field. startLoad() corresponds to 'startLoad', markRendering() to 'markRendering', and so on.

Element 4: Pure Helper Functions

Stateless functions that the factory uses internally:

function canTransition(from: PageLoadState, to: PageLoadState): boolean {
  switch (from) {
    case 'idle':            return to === 'loading';
    case 'loading':         return to === 'rendering' || to === 'error';
    case 'rendering':       return to === 'postProcessing' || to === 'error';
    case 'postProcessing':  return to === 'done' || to === 'error';
    default:                return false;
  }
}

The canTransition function is the machine's transition logic. It is pure — no state, no side effects, no dependencies. It is also a data source for the inference engine (Part VIII), which reads the switch cases to infer transitions.

Element 5: The Factory Function + Companion Class

The factory creates the machine instance. The companion carries the metadata:

@FiniteStateMachine({ /* descriptor */ })
export class PageLoadStateFsm {}

export function createPageLoadState(callbacks: PageLoadCallbacks): PageLoadMachine {
  let state: PageLoadState = 'idle';

  function transition(to: PageLoadState): void {
    if (!canTransition(state, to)) return;
    const from = state;
    state = to;
    callbacks.onStateChange(from, to);
  }

  return {
    getState: () => state,
    startLoad: () => transition('loading'),
    markRendering: () => transition('rendering'),
    markPostProcessing: () => transition('postProcessing'),
    markDone: () => transition('done'),
    markError: (error: Error) => {
      transition('error');
      callbacks.onError(error);
    },
  };
}

The factory takes callbacks, creates a closure over state, and returns the machine interface. The machine is pure — it reads and writes state, calls callbacks, and does nothing else. No DOM access. No event bus. No timers. No network. Pure.

Diagram
The five-element template — every machine follows this structure. The decorator sits on the companion class at the top.

Four Decorator Examples

Let us examine four real machines — each chosen because it reveals something different about the decorator pattern.

Example 1: PageLoadStateFsm — The Full Lifecycle

@FiniteStateMachine({
  states: ['idle', 'loading', 'rendering', 'postProcessing', 'done', 'error'] as const,
  events: ['startLoad', 'markRendering', 'markPostProcessing', 'markDone', 'markError'] as const,
  transitions: [
    { from: 'idle', to: 'loading', on: 'startLoad' },
    { from: 'loading', to: 'rendering', on: 'markRendering' },
    { from: 'rendering', to: 'postProcessing', on: 'markPostProcessing' },
    { from: 'postProcessing', to: 'done', on: 'markDone' },
    { from: '*', to: 'error', on: 'markError' },
  ] as const,
  emits: ['app-ready', 'toc-headings-rendered'] as const,
  listens: [] as const,
  guards: ['staleGeneration'] as const,
  feature: { id: 'PAGE-LOAD', ac: 'fullLifecycle' } as const,
})
export class PageLoadStateFsm {}

What this reveals:

Six states, five events, five transitions. This is a linear pipeline — idle → loading → rendering → postProcessing → done — with a wildcard error state. The from: '*' on markError means an error can occur from any state. The extractor expands this to five transitions: idle → error, loading → error, rendering → error, postProcessing → error, done → error.

Emits two events, listens to none. PageLoad is a producer — it tells other machines when the page is ready (app-ready) and when TOC headings have been rendered (toc-headings-rendered). It never reacts to events from other machines.

One guard: staleGeneration. The page load machine uses a generation counter to handle rapid navigations. If a navigation starts while a previous one is still loading, the previous navigation's callbacks are stale — they belong to an old generation. The staleGeneration guard prevents stale callbacks from causing transitions.

Has a feature link. PAGE-LOAD / fullLifecycle connects this machine to the page load feature's "full lifecycle" acceptance criterion. The compliance scanner verifies this link.

Diagram
The PageLoadState statechart — a linear pipeline with wildcard error handling. Every state can transition to error.

Example 2: AppReadinessFsm — The Barrier with Guards

@FiniteStateMachine({
  states: ['pending', 'ready'] as const,
  events: ['navPanePainted', 'markdownOutputRendered', 'reset'] as const,
  transitions: [
    { from: 'pending', to: 'ready',   on: 'navPanePainted',        when: 'allReceived' },
    { from: 'pending', to: 'ready',   on: 'markdownOutputRendered', when: 'allReceived' },
    { from: 'pending', to: 'pending', on: 'navPanePainted',        when: 'notAllReceived' },
    { from: 'pending', to: 'pending', on: 'markdownOutputRendered', when: 'notAllReceived' },
    { from: 'ready',   to: 'pending', on: 'reset' },
  ] as const,
  guards: ['allReceived', 'notAllReceived'] as const,
  emits: ['app-ready', 'app-route-ready'] as const,
  scope: 'singleton',
})
export class AppReadinessFsm {}

What this reveals:

Two states, three events, five transitions. This is a barrier synchronization machine. It waits in pending until all required signals have arrived, then transitions to ready. The reset event sends it back to pending for the next navigation.

Guard-dependent transitions. The same event (navPanePainted) causes two different transitions depending on the guard:

  • If allReceived is true: pending → ready (all signals received, the app is ready).
  • If notAllReceived is true: pending → pending (signal recorded, but still waiting for more).

This is a pattern the interactive explorer renders as a decision diamond — two edges from the same source with different guard labels.

No feature link, no listens. AppReadiness is infrastructure — it coordinates the "ready" signal for other machines but does not implement a user-facing feature. It emits app-ready and app-route-ready but listens to nothing through the event bus (its inputs come from direct method calls by the adapter).

Scope: singleton. There is exactly one AppReadiness machine for the entire application. It persists across navigations and resets itself.

Example 3: TerminalDotsStateFsm — DOM Interaction via Events

@FiniteStateMachine({
  states: ['normal', 'focus', 'closed'] as const,
  events: ['minimize', 'maximize', 'close', 'syncSidebarMasked'] as const,
  transitions: [
    { from: 'normal', to: 'focus',  on: 'maximize' },
    { from: 'focus',  to: 'normal', on: 'minimize' },
    { from: 'focus',  to: 'normal', on: 'maximize' },
    { from: 'normal', to: 'closed', on: 'close' },
    { from: 'focus',  to: 'closed', on: 'close' },
  ] as const,
  emits:   [] as const,
  listens: ['sidebar-mask-change'] as const,
  feature: { id: 'TERMINAL-DOTS', ac: 'maximizeEntersFocusMode' } as const,
})
export class TerminalDotsStateFsm {}

What this reveals:

Three states, four events, five transitions. The terminal dots are the minimize/maximize/close buttons on the terminal-style code blocks. normal is the default view, focus is maximized (full viewport), closed hides the terminal.

Toggle behavior. maximize from normal enters focus. maximize from focus returns to normal. The same event has different targets depending on the current state — this is what makes it a state machine rather than a simple boolean.

Emits nothing, listens to one event. TerminalDots is a consumer. When the sidebar opens (sidebar-mask-change), the terminal dots adapter calls syncSidebarMasked() to adjust the terminal's layout. The machine itself does not emit events — it is a leaf node in the event topology.

Has a feature link. TERMINAL-DOTS / maximizeEntersFocusMode connects to the acceptance criterion that clicking maximize should enter focus mode. This is a specific, testable behavior — not a vague feature description.

Example 4: EventBusFsm — The Degenerate Machine

@FiniteStateMachine({
  states: ['ready'] as const,
  events: ['emit', 'on', 'unsubscribe'] as const,
  description: 'Typed event bus — mediates all cross-FSM custom events with compile-time safety.',
  transitions: [
    { from: 'ready', to: 'ready', on: 'emit' },
    { from: 'ready', to: 'ready', on: 'on' },
    { from: 'ready', to: 'ready', on: 'unsubscribe' },
  ] as const,
  scope: 'singleton',
})
export class EventBusFsm {}

What this reveals:

One state, three events, three self-loops. The event bus is always ready. It never transitions to a different state. Every event loops back to ready. This is a degenerate machine — technically a state machine, but with no meaningful state transitions.

Why include it? Because the build-time extractor discovers machines by finding classes with @FiniteStateMachine. If the event bus is not decorated, it is invisible to the state graph. Including it makes the topology complete — the graph shows the bus as a node connected to every machine that emits or listens.

Description field. This is one of the few machines that uses the description field. The interactive explorer shows this description in a tooltip when hovering over the EventBus node.

No feature link. The event bus is infrastructure. It does not implement a user-facing feature. The compliance scanner exempts it.

Scope: singleton. One bus for the entire application.

How the Build-Time Extractor Reads Decorators

This is a preview of Part IX. The full extractor is covered there; here we focus on the decorator-reading aspect.

The build-time extractor is a TypeScript program that uses the TypeScript Compiler API (ts.createProgram, ts.forEachChild, etc.) to walk the AST of every source file. For each file, it:

  1. Finds class declarations whose name ends in Fsm.
  2. Checks for the @FiniteStateMachine decorator by inspecting the class's modifiers array for a ts.Decorator node.
  3. Reads the decorator argument — the object literal passed to FiniteStateMachine().
  4. Extracts each field by walking the object literal's properties and reading their initializer expressions.

AST Structure of a Decorator

When the TypeScript compiler parses:

@FiniteStateMachine({
  states: ['idle', 'loading'] as const,
  emits: ['app-ready'] as const,
})
export class PageLoadStateFsm {}

The AST looks like (simplified):

ClassDeclaration
  name: "PageLoadStateFsm"
  modifiers:
    - Decorator
        expression: CallExpression
          expression: Identifier "FiniteStateMachine"
          arguments:
            - ObjectLiteralExpression
                properties:
                  - PropertyAssignment
                      name: "states"
                      initializer: AsExpression
                        expression: ArrayLiteralExpression
                          elements: [StringLiteral "idle", StringLiteral "loading"]
                        type: TypeReference "const"
                  - PropertyAssignment
                      name: "emits"
                      initializer: AsExpression
                        expression: ArrayLiteralExpression
                          elements: [StringLiteral "app-ready"]
                        type: TypeReference "const"

The extractor navigates this tree:

  1. Find the Decorator modifier.
  2. Check that the CallExpression's identifier is FiniteStateMachine.
  3. Get the first argument (the ObjectLiteralExpression).
  4. For each PropertyAssignment, read the name and unwrap the AsExpression to get the ArrayLiteralExpression.
  5. For each element of the array, read the StringLiteral's text.

This is why as const matters — without it, the initializer might be an ArrayLiteralExpression whose elements are Identifier nodes (references to variables) rather than StringLiteral nodes (inline values). The extractor handles only literals. Variables require resolution, which is a different (harder) problem.

The Output: state-machines.json

The extractor produces data/state-machines.json — a JSON file describing all 43 machines, 10 adapters, and 27 edges (imports, events, compositions). Each machine entry mirrors the FsmDescriptor:

{
  "name": "PageLoadStateFsm",
  "file": "src/lib/page-load-state.ts",
  "states": ["idle", "loading", "rendering", "postProcessing", "done", "error"],
  "events": ["startLoad", "markRendering", "markPostProcessing", "markDone", "markError"],
  "transitions": [
    { "from": "idle", "to": "loading", "on": "startLoad" },
    { "from": "loading", "to": "rendering", "on": "markRendering" },
    { "from": "*", "to": "error", "on": "markError" }
  ],
  "emits": ["app-ready", "toc-headings-rendered"],
  "listens": [],
  "guards": ["staleGeneration"],
  "feature": { "id": "PAGE-LOAD", "ac": "fullLifecycle" },
  "scope": null
}

This JSON file is the "database" in the title. The decorator becomes a database entry — structured, queryable, and consumed by multiple downstream tools: the interactive explorer, the topology scanner, the compliance scanner, and the drift detection gate.

What the Decorator Does NOT Do

It is important to be precise about the decorator's boundaries. The @FiniteStateMachine decorator is metadata, not logic. Here is what it does not do.

It Does Not Enforce State Transitions at Runtime

The decorator declares transitions, but it does not enforce them. The actual transition logic lives in the canTransition function inside the factory:

function canTransition(from: PageLoadState, to: PageLoadState): boolean {
  switch (from) {
    case 'idle': return to === 'loading';
    // ...
  }
}

If the canTransition function allows a transition that the decorator does not declare (or vice versa), the decorator does not produce an error. The mismatch is detected at build time by the extractor's validation pass, or at test time by comparing getFsmDescriptor() output to actual machine behavior.

This is a deliberate design choice. The decorator is a declaration of intent — "this machine is supposed to have these transitions." The factory is the implementation of that intent. They can drift. The build pipeline catches the drift.

It Does Not Validate emits/listens Against Actual Code

The decorator says emits: ['app-ready'], but it does not check that the adapter actually calls bus.emit(AppReady). That validation is the topology scanner's job (Part VII). The scanner walks the source code, finds every bus.emit() and bus.on() call, cross-references them against the decorator's emits and listens, and reports mismatches.

It Does Not Generate Code

The decorator does not generate TypeScript code. It does not create interfaces, types, or functions. It is a passive annotation, not a code generator. The build pipeline reads it; the decorator itself does nothing beyond attaching the descriptor to the class.

It Does Not Replace Documentation

The descriptor is machine-readable metadata, not human-readable prose. The description field is a one-liner, not a full explanation. The machine's purpose, design rationale, and usage instructions live in code comments, JSDoc, and (for complex machines) blog posts like the ones in this series.

It Is Not a Runtime Library

There is no runtime dependency. The decorator function is ~10 lines. Object.defineProperty, one Symbol, done. No classes to instantiate, no services to configure, no framework to integrate. The decorator is a pattern, not a library.

The Full Picture

The @FiniteStateMachine decorator is the hinge between two worlds:

  • The human world — where developers read the decorator to understand a machine's states, transitions, events, and purpose.
  • The machine world — where the TypeScript Compiler API reads the decorator to build state-machines.json, which feeds the topology scanner, the interactive explorer, and the compliance scanner.

The decorator is declared once, in the source file, next to the implementation. It is consumed by four different tools:

  1. Developers — reading the source code.
  2. Unit tests — via getFsmDescriptor().
  3. The build-time extractor — via the TypeScript Compiler API.
  4. The topology scanner — via the extractor's JSON output.

One declaration, four consumers. That is why the title says "the decorator that becomes a database" — the decorator is the single source of truth from which all downstream representations are derived.

The next part puts this pattern to work across all 43 machines — a tour of representative machines showing the range of patterns: toggles, timers, compound states, barriers, navigation classifiers, typewriter animations, and scroll spy machines.

Continue to Part IV: A Tour of 43 Pure Machines →

⬇ Download