@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 {}@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 };
}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 forcreatePageLoadState()AppReadinessFsm— companion forcreateAppReadiness()TerminalDotsStateFsm— companion forcreateTerminalDotsState()EventBusFsm— companion forcreateEventBus()
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 ...
}// 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;
}/**
* @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 conststates: ['idle', 'loading', 'rendering', 'postProcessing', 'done', 'error'] as constThe 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 constevents: ['startLoad', 'markRendering', 'markPostProcessing', 'markDone', 'markError'] as constThese 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 consttransitions: [
{ from: 'idle', to: 'loading', on: 'startLoad' },
{ from: 'loading', to: 'rendering', on: 'markRendering' },
{ from: '*', to: 'error', on: 'markError' },
] as constThe 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 constemits: ['app-ready', 'toc-headings-rendered'] as constThese 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 constlistens: ['sidebar-mask-change'] as constSame 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 constguards: ['staleGeneration'] as constGuards 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.
feature?: FsmFeatureLink
A link to the Feature abstract class and acceptance criterion that this machine implements:
feature: { id: 'PAGE-LOAD', ac: 'fullLifecycle' }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.'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).
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;
}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 instates, or'*'for a wildcard.to— the target state. Must be one of the strings instates.on— the event (trigger) that causes the transition. Must be one of the strings inevents.when— an optional guard condition. If present, the transition fires only when this predicate is true. Must be one of the strings inguards.
Reading a Transition
{ from: 'loading', to: 'rendering', on: 'markRendering' }{ 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' }{ 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' }{ 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.
FsmFeatureLink — Connecting to Requirements
export interface FsmFeatureLink {
readonly id: string;
readonly ac: string;
}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' }feature: { id: 'PAGE-LOAD', ac: 'fullLifecycle' }id— the feature identifier. Matches aFeature_*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; theacnames which one this machine addresses.
Why Every Machine Should Have a Feature Link
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.
How the Compliance Scanner Uses It
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 ✓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']// 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 conststates: ['idle', 'loading', 'done'] as constThe 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']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' }
];
}// 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');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,
});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;
};
}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.
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;
}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' });
});
});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:
- Documentation — the test file is a readable specification of what the decorator declares.
- 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';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;
}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;
}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;
}
}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);
},
};
}@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.
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 {}@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.
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 {}@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
allReceivedis true:pending → ready(all signals received, the app is ready). - If
notAllReceivedis 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 {}@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 {}@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:
- Finds class declarations whose name ends in
Fsm. - Checks for the
@FiniteStateMachinedecorator by inspecting the class'smodifiersarray for ats.Decoratornode. - Reads the decorator argument — the object literal passed to
FiniteStateMachine(). - 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 {}@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"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:
- Find the
Decoratormodifier. - Check that the
CallExpression's identifier isFiniteStateMachine. - Get the first argument (the
ObjectLiteralExpression). - For each
PropertyAssignment, read thenameand unwrap theAsExpressionto get theArrayLiteralExpression. - 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
}{
"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';
// ...
}
}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:
- Developers — reading the source code.
- Unit tests — via
getFsmDescriptor(). - The build-time extractor — via the TypeScript Compiler API.
- 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.