Skip to content

@studnicky/fsm

Type-safe abstract FSM base class with async effect interpretation and named machine registry.

Install

bash
pnpm add @studnicky/fsm

Requires @studnicky:registry=https://npm.pkg.github.com in .npmrc.

Usage

Define a StateMachine subclass with getInitialState and reduce, then drive it with EffectInterpreter. The interpreter manages the mailbox, dispatches effects, and notifies subscribers on every state change:

ts
import type { EffectHandlerMapType, FsmStepType } from '../src/index.js';
import type { TrafficEffectEntity } from './entities/TrafficEffectEntity.js';
import type { TrafficEventEntity } from './entities/TrafficEventEntity.js';

import { EffectInterpreter, StateMachine } from '../src/index.js';

type TrafficState =
  | { readonly 'variant': 'red' }
  | { readonly 'variant': 'green' }
  | { readonly 'variant': 'amber' };

type TrafficEvent = TrafficEventEntity.Type;

type TrafficEffect = TrafficEffectEntity.Type;

class TrafficLight extends StateMachine<TrafficState, TrafficEvent, TrafficEffect> {
  static make(): TrafficLight { return new TrafficLight(); }

  getInitialState(): TrafficState {
    return { 'variant': 'red' };
  }

  reduce(state: TrafficState, event: TrafficEvent): FsmStepType<TrafficState, TrafficEffect> {
    if (event.type === 'advance') {
      if (state.variant === 'red')   {return { 'effects': [], 'state': { 'variant': 'green' } };}
      if (state.variant === 'green') {return { 'effects': [{ 'tone': 'chime', 'variant': 'playSound' }], 'state': { 'variant': 'amber' } };}
      if (state.variant === 'amber') {return { 'effects': [], 'state': { 'variant': 'red' } };}
    }
    return { 'effects': [], 'state': state };
  }
}

class TrafficLightDemo {
  static readonly soundsPlayed: string[] = [];
  static readonly history: string[] = [];

  static readonly handlers: EffectHandlerMapType<TrafficEffect> = {
    'playSound': (effect) => { TrafficLightDemo.soundsPlayed.push(effect.tone); }
  };

  static async run(): Promise<{ readonly 'finalVariant': TrafficState['variant']; readonly 'history': string[]; readonly 'soundsPlayed': string[] }> {
    const machine: TrafficLight = TrafficLight.make();
    const interpreter: EffectInterpreter<TrafficState, TrafficEvent, TrafficEffect> = EffectInterpreter.create({ 'handlers': TrafficLightDemo.handlers, 'machine': machine, 'machineId': 'test-light' });

    const unsubscribe = interpreter.subscribe((state) => { TrafficLightDemo.history.push(state.variant); });

    interpreter.start();

    await interpreter.send({ 'type': 'advance' });
    await interpreter.send({ 'type': 'advance' });
    await interpreter.send({ 'type': 'advance' });

    console.log('State after 3 advances:', interpreter.getState().variant);
    console.log('Sound history:', TrafficLightDemo.soundsPlayed);
    console.log('State history:', TrafficLightDemo.history);

    unsubscribe();
    interpreter.stop();

    return { 'finalVariant': interpreter.getState().variant, 'history': TrafficLightDemo.history, 'soundsPlayed': TrafficLightDemo.soundsPlayed };
  }
}

const results = await TrafficLightDemo.run();

MachineRegistry: named singleton registry

MachineRegistry is a process-scoped store for named interpreter instances. Registering under a key makes the interpreter accessible from any module without passing references:

ts
import type { FsmStepType } from '../src/index.js';
import type { ToggleEventEntity } from './entities/ToggleEventEntity.js';

import { EffectInterpreter, MachineAlreadyRegisteredError, MachineRegistry, StateMachine } from '../src/index.js';

type ToggleState = { readonly 'variant': 'on' } | { readonly 'variant': 'off' };
type ToggleEvent = ToggleEventEntity.Type;

class Toggle extends StateMachine<ToggleState, ToggleEvent> {
  static make(): Toggle { return new Toggle(); }

  getInitialState(): ToggleState {
    return { 'variant': 'off' };
  }

  reduce(state: ToggleState, event: ToggleEvent): FsmStepType<ToggleState> {
    if (event.type === 'toggle') {
      return { 'effects': [], 'state': { 'variant': state.variant === 'off' ? 'on' : 'off' } };
    }
    return { 'effects': [], 'state': state };
  }
}

const interpreter: EffectInterpreter<ToggleState, ToggleEvent> = EffectInterpreter.create({ 'machine': Toggle.make(), 'machineId': 'toggle-a' });
interpreter.start();

const registry = MachineRegistry.create();

// Registry is empty before registration
assert.equal(registry.has('toggle-a'), false);
assert.deepEqual(registry.list(), []);

registry.register('toggle-a', interpreter);
assert.equal(registry.has('toggle-a'), true);
assert.deepEqual(registry.list(), ['toggle-a']);

// Duplicate registration throws MachineAlreadyRegisteredError
assert.throws(
  () => { registry.register('toggle-a', interpreter); },
  MachineAlreadyRegisteredError
);

// Retrieve and drive the machine through the registry
const found = registry.get('toggle-a');
assert.ok(found !== undefined);

await found.send({ 'type': 'toggle' });
assert.equal(interpreter.getState().variant, 'on');

await found.send({ 'type': 'toggle' });
assert.equal(interpreter.getState().variant, 'off');

// Unknown name returns undefined
assert.equal(registry.get('unknown'), undefined);

registry.unregister('toggle-a');
assert.equal(registry.has('toggle-a'), false);

interpreter.stop();

console.log('Registry state:', registry.list());

Error handling

StateMachine.transition wraps any throw from reduce in a ReducerThrewError. EffectInterpreter guards against reads before start() and sends after stop():

ts
import type { FsmStepType } from '../src/index.js';
import type { BrokenEventEntity } from './entities/BrokenEventEntity.js';
import type { BrokenStateEntity } from './entities/BrokenStateEntity.js';
import type { SimpleEventEntity } from './entities/SimpleEventEntity.js';
import type { SimpleStateEntity } from './entities/SimpleStateEntity.js';

import {
  EffectInterpreter,
  InterpreterNotRunningError,
  InterpreterNotStartedError,
  ReducerThrewError,
  StateMachine
} from '../src/index.js';

// --- ReducerThrewError ---

type BrokenState = BrokenStateEntity.Type;
type BrokenEvent = BrokenEventEntity.Type;

class BrokenMachine extends StateMachine<BrokenState, BrokenEvent> {
  static make(): BrokenMachine { return new BrokenMachine(); }

  getInitialState(): BrokenState {
    return { 'variant': 'active' };
  }

  reduce(_state: BrokenState, event: BrokenEvent): FsmStepType<BrokenState> {
    if (event.type === 'boom') { throw new Error('reducer exploded'); }
    return { 'effects': [], 'state': _state };
  }
}

const broken: BrokenMachine = BrokenMachine.make();
const initialState: BrokenState = { 'variant': 'active' };

// StateMachine.transition wraps reducer throws in ReducerThrewError
assert.throws(
  () => { broken.transition(initialState, { 'type': 'boom' }); },
  ReducerThrewError
);

console.log('ReducerThrewError thrown and caught');

// --- InterpreterNotStartedError ---

type SimpleState = SimpleStateEntity.Type;
type SimpleEvent = SimpleEventEntity.Type;

class SimpleMachine extends StateMachine<SimpleState, SimpleEvent> {
  static make(): SimpleMachine { return new SimpleMachine(); }
  getInitialState(): SimpleState { return { 'variant': 'idle' }; }
  reduce(state: SimpleState): FsmStepType<SimpleState> { return { 'effects': [], 'state': state }; }
}

const notStarted: EffectInterpreter<SimpleState, SimpleEvent> = EffectInterpreter.create({ 'machine': SimpleMachine.make() });

// getState before start() throws InterpreterNotStartedError
assert.throws(
  () => { notStarted.getState(); },
  InterpreterNotStartedError
);

console.log('InterpreterNotStartedError thrown and caught');

// --- InterpreterNotRunningError ---

const stopped: EffectInterpreter<SimpleState, SimpleEvent> = EffectInterpreter.create({ 'machine': SimpleMachine.make() });
stopped.start();
stopped.stop();

// send after stop() throws InterpreterNotRunningError
await assert.rejects(
  async () => { await stopped.send({ 'type': 'noop' }); },
  InterpreterNotRunningError
);

console.log('InterpreterNotRunningError thrown and caught');

Observability hooks

Every stateful class exposes protected hook methods that fire at each significant stage. Override them in a subclass to add logging, tracing, or metrics without changing any public behaviour.

StateMachine hooks

HookWhen it firesArgs
onTransition(from, to, event)After a successful state-variant change, before the step is returnedfrom: TState, to: TState, event: TEvent
onEnterState(state)When entering a new state variant (fires after onTransition)state: TState
onExitState(state)When leaving the current state variant (fires before onTransition)state: TState
onTransitionRejected(state, event, reason)When reduce throws — no valid transition / guard failedstate: TState, event: TEvent, reason: string

onTransition, onEnterState, and onExitState are only called when the state variant changes. Self-loops (same variant returned) fire none of them.

EffectInterpreter hooks

HookWhen it firesArgs
onStart(state)After start() sets the initial statestate: TState
onStop(state)After stop() halts event processingstate: TState | undefined
onEnqueue(event)When an event is added to the mailbox by send()event: TEvent
onTransition(from, to, event)When the interpreter commits a state-variant changefrom: TState, to: TState, event: TEvent
onEnterState(state)After committing a new state variantstate: TState
onExitState(state)Before committing the new state, while still in the old variantstate: TState
onEffectStart(effect)Before invoking an effect handlereffect: TEffect
onEffectSuccess(effect)After an effect handler resolves successfullyeffect: TEffect
onEffectError(effect, error)When an effect handler throwseffect: TEffect, error: Error

MachineRegistry hooks

MachineRegistry is a static-only class; hooks are protected static methods. Override them in a subclass and route all calls through the subclass.

HookWhen it firesArgs
onRegister(id)After a named interpreter is successfully registeredid: string
onUnregister(id)After unregister() is called (fires even if the key did not exist)id: string
onResolveMiss(id)When get() returns undefined for an unknown idid: string

Example — traced traffic light

ts
import type { EffectHandlerMapType, FsmStepType } from '../src/index.js';
import type { TrafficEffectEntity } from './entities/TrafficEffectEntity.js';
import type { TrafficEventEntity } from './entities/TrafficEventEntity.js';

import { EffectInterpreter, MachineRegistry, StateMachine } from '../src/index.js';

// --- Domain types ---

type TrafficState =
  | { readonly 'variant': 'amber' }
  | { readonly 'variant': 'green' }
  | { readonly 'variant': 'red' };

type TrafficEvent = TrafficEventEntity.Type;

type TrafficEffect = TrafficEffectEntity.Type;

// --- Observed StateMachine subclass ---

class ObservedTrafficMachine extends StateMachine<TrafficState, TrafficEvent, TrafficEffect> {
  static make(): ObservedTrafficMachine { return new ObservedTrafficMachine(); }

  getInitialState(): TrafficState { return { 'variant': 'red' }; }

  reduce(state: TrafficState, event: TrafficEvent): FsmStepType<TrafficState, TrafficEffect> {
    if (event.type === 'advance') {
      if (state.variant === 'red')   { return { 'effects': [], 'state': { 'variant': 'green' } }; }
      if (state.variant === 'green') { return { 'effects': [{ 'tone': 'chime', 'variant': 'playSound' }], 'state': { 'variant': 'amber' } }; }
      if (state.variant === 'amber') { return { 'effects': [], 'state': { 'variant': 'red' } }; }
    }
    return { 'effects': [], 'state': state };
  }

  protected override onTransition(from: TrafficState, to: TrafficState, event: TrafficEvent): void {
    console.log(`[fsm:machine] transition  ${from.variant} --[${event.type}]--> ${to.variant}`);
  }

  protected override onEnterState(state: TrafficState): void {
    console.log(`[fsm:machine] enter       state=${state.variant}`);
  }

  protected override onExitState(state: TrafficState): void {
    console.log(`[fsm:machine] exit        state=${state.variant}`);
  }

  protected override onTransitionRejected(state: TrafficState, event: TrafficEvent, reason: string): void {
    console.log(`[fsm:machine] rejected    state=${state.variant} event=${event.type} reason=${reason}`);
  }
}

// --- Observed EffectInterpreter subclass ---

class ObservedInterpreter extends EffectInterpreter<TrafficState, TrafficEvent, TrafficEffect> {
  static makeObserved(
    machine: ObservedTrafficMachine,
    handlers: EffectHandlerMapType<TrafficEffect>
  ): ObservedInterpreter {
    return new ObservedInterpreter({ 'handlers': handlers, 'machine': machine, 'machineId': 'traffic-light' });
  }

  protected override onStart(state: TrafficState): void {
    console.log(`[fsm:interp]  start       initialState=${state.variant}`);
  }

  protected override onStop(state: TrafficState | undefined): void {
    console.log(`[fsm:interp]  stop        lastState=${state?.variant ?? 'unknown'}`);
  }

  protected override onEnqueue(event: TrafficEvent): void {
    console.log(`[fsm:interp]  enqueue     event=${event.type}`);
  }

  protected override onTransition(from: TrafficState, to: TrafficState, event: TrafficEvent): void {
    console.log(`[fsm:interp]  transition  ${from.variant} --[${event.type}]--> ${to.variant}`);
  }

  protected override onEnterState(state: TrafficState): void {
    console.log(`[fsm:interp]  enter       state=${state.variant}`);
  }

  protected override onExitState(state: TrafficState): void {
    console.log(`[fsm:interp]  exit        state=${state.variant}`);
  }

  protected override onEffectStart(effect: TrafficEffect): void {
    console.log(`[fsm:interp]  effectStart variant=${effect.variant} tone=${effect.tone}`);
  }

  protected override onEffectSuccess(effect: TrafficEffect): void {
    console.log(`[fsm:interp]  effectOk    variant=${effect.variant}`);
  }

  protected override onEffectError(effect: TrafficEffect, error: Error): void {
    console.log(`[fsm:interp]  effectError variant=${effect.variant} error=${error.message}`);
  }
}

// --- Observed MachineRegistry subclass ---

class ObservedRegistry extends MachineRegistry {
  static override create(): ObservedRegistry {
    return new ObservedRegistry();
  }

  protected override onRegister(id: string): void {
    console.log(`[fsm:registry] register   id=${id}`);
  }

  protected override onUnregister(id: string): void {
    console.log(`[fsm:registry] unregister id=${id}`);
  }

  protected override onResolveMiss(id: string): void {
    console.log(`[fsm:registry] miss       id=${id}`);
  }
}

// --- Scenario: drive the traffic light through a full cycle ---

const soundsPlayed: string[] = [];
const handlers: EffectHandlerMapType<TrafficEffect> = {
  'playSound': (effect) => { soundsPlayed.push(effect.tone); }
};

const machine = ObservedTrafficMachine.make();
const interpreter = ObservedInterpreter.makeObserved(machine, handlers);

const observedRegistry = ObservedRegistry.create();

console.log('\n--- Registering interpreter ---');
observedRegistry.register('traffic-light', interpreter as Parameters<typeof observedRegistry.register>[1]);

console.log('\n--- Starting interpreter ---');
interpreter.start();

console.log('\n--- Sending events: advance × 3 ---');
await interpreter.send({ 'type': 'advance' }); // red → green
await interpreter.send({ 'type': 'advance' }); // green → amber (plays sound)
await interpreter.send({ 'type': 'advance' }); // amber → red

console.log('\n--- Probing a missing registry key ---');
observedRegistry.get('no-such-machine');

console.log('\n--- Stopping interpreter ---');
interpreter.stop();

console.log('\n--- Unregistering ---');
observedRegistry.unregister('traffic-light');

console.log('\nFinal state:', interpreter.getState().variant);
console.log('Sounds played:', soundsPlayed);

The base class never calls any logger or metrics library. All hooks are no-ops by default.

API

ExportTypeDescription
StateMachine<TState, TEvent, TEffect>abstract classBase FSM; implement getInitialState and reduce
EffectInterpreter<TState, TEvent, TEffect>classDrives a machine; use EffectInterpreter.create() or EffectInterpreter.builder()
EffectInterpreterBuilder<TState, TEvent, TEffect>classFluent builder for EffectInterpreter
MachineRegistryclassProcess-scoped named registry of interpreters
FsmStepType<TState, TEffect>type{ state, effects } (return value of reduce)
FsmTransitionType<TState, TEvent, TEffect>typeFunction signature for standalone transition functions
EffectHandlerMapType<TEffect>typeVariant-keyed async handler map
MachineAlreadyRegisteredErrorclassThrown by MachineRegistry.register on duplicate name
ReducerThrewErrorclassThrown by StateMachine.transition when reduce throws

StateMachine<TState, TEvent, TEffect>

MemberSignatureDescription
getInitialState() => TStateReturns the machine's initial state
reduce(state, event) => FsmStepType<TState, TEffect>Pure transition function
transition(state, event) => FsmStepType<TState, TEffect>Calls reduce; wraps throws in ReducerThrewError

EffectInterpreter<TState, TEvent, TEffect>

MemberSignatureDescription
start() => voidInitialises state; must be called before send
stop() => voidHalts event processing
getState() => TStateReturns current state; throws if not started
send(event: TEvent) => Promise<void>Enqueues event and drains mailbox
subscribe(observer) => () => voidRegisters a state observer; returns unsubscribe fn

Try it

Run the examples below directly in the browser to see the FSM primitives in action.

Builder

The builder chains machine, handlers, and options before calling build() — the interpreter is ready to start with no constructor parameters.

FSM builder
/** builder-fsm — construct an EffectInterpreter via the fluent builder API. Run: npx tsx packages/fsm/examples/builder-fsm.ts */

import assert from 'node:assert/strict';

// #region usage
import type { EffectHandlerMapType, FsmStepType } from '../src/index.js';
import type { TrafficEffectEntity } from './entities/TrafficEffectEntity.js';
import type { TrafficEventEntity } from './entities/TrafficEventEntity.js';

import { EffectInterpreter, StateMachine } from '../src/index.js';

type TrafficState =
  | { readonly 'variant': 'red' }
  | { readonly 'variant': 'green' }
  | { readonly 'variant': 'amber' };

type TrafficEvent = TrafficEventEntity.Type;

type TrafficEffect = TrafficEffectEntity.Type;

class TrafficLight extends StateMachine<TrafficState, TrafficEvent, TrafficEffect> {
  static make(): TrafficLight { return new TrafficLight(); }

  getInitialState(): TrafficState {
    return { 'variant': 'red' };
  }

  reduce(state: TrafficState, event: TrafficEvent): FsmStepType<TrafficState, TrafficEffect> {
    if (event.type === 'advance') {
      if (state.variant === 'red')   { return { 'effects': [], 'state': { 'variant': 'green' } }; }
      if (state.variant === 'green') { return { 'effects': [{ 'tone': 'chime', 'variant': 'playSound' }], 'state': { 'variant': 'amber' } }; }
      if (state.variant === 'amber') { return { 'effects': [], 'state': { 'variant': 'red' } }; }
    }
    return { 'effects': [], 'state': state };
  }
}

class BuilderFsmDemo {
  static readonly soundsPlayed: string[] = [];
  static readonly history: string[] = [];

  static readonly handlers: EffectHandlerMapType<TrafficEffect> = {
    'playSound': (effect) => {
      BuilderFsmDemo.soundsPlayed.push(effect.tone);
      console.log(`  effect handler: playSound tone="${effect.tone}"`);
    }
  };

  static async run(): Promise<{ readonly 'finalVariant': TrafficState['variant']; readonly 'history': string[]; readonly 'soundsPlayed': string[] }> {
    const machine: TrafficLight = TrafficLight.make();

    const interpreter = EffectInterpreter.builder<TrafficState, TrafficEvent, TrafficEffect>()
      .withMachine(machine)
      .withHandlers(BuilderFsmDemo.handlers)
      .withOptions({ 'machineId': 'builder-demo' })
      .build();

    const unsubscribe = interpreter.subscribe((state) => { BuilderFsmDemo.history.push(state.variant); });

    interpreter.start();
    console.log('started — initial state:', interpreter.getState().variant);

    await interpreter.send({ 'type': 'advance' });
    console.log('after advance 1:', interpreter.getState().variant);

    await interpreter.send({ 'type': 'advance' });
    console.log('after advance 2:', interpreter.getState().variant);

    await interpreter.send({ 'type': 'advance' });
    console.log('after advance 3:', interpreter.getState().variant);

    console.log('State history:', BuilderFsmDemo.history);
    console.log('Sounds played:', BuilderFsmDemo.soundsPlayed);

    unsubscribe();
    interpreter.stop();

    return { 'finalVariant': interpreter.getState().variant, 'history': BuilderFsmDemo.history, 'soundsPlayed': BuilderFsmDemo.soundsPlayed };
  }
}

const results = await BuilderFsmDemo.run();
// #endregion usage

assert.equal(results.finalVariant, 'red');
assert.deepEqual(results.soundsPlayed, ['chime']);
assert.deepEqual(results.history, ['red', 'green', 'amber', 'red']);

console.log('builder-fsm: all assertions passed');
Output
Press Execute to run this example against the real library.

Lifecycle hooks

Every state transition fires hooks on both the machine and interpreter layers — watch the paired log lines as each advance propagates.

FSM lifecycle hooks
/** observedFsm — trace lifecycle hooks across StateMachine, EffectInterpreter, and MachineRegistry. Run: npx tsx examples/observedFsm.ts */

import assert from 'node:assert/strict';

// #region usage
import type { EffectHandlerMapType, FsmStepType } from '../src/index.js';
import type { TrafficEffectEntity } from './entities/TrafficEffectEntity.js';
import type { TrafficEventEntity } from './entities/TrafficEventEntity.js';

import { EffectInterpreter, MachineRegistry, StateMachine } from '../src/index.js';

// --- Domain types ---

type TrafficState =
  | { readonly 'variant': 'amber' }
  | { readonly 'variant': 'green' }
  | { readonly 'variant': 'red' };

type TrafficEvent = TrafficEventEntity.Type;

type TrafficEffect = TrafficEffectEntity.Type;

// --- Observed StateMachine subclass ---

class ObservedTrafficMachine extends StateMachine<TrafficState, TrafficEvent, TrafficEffect> {
  static make(): ObservedTrafficMachine { return new ObservedTrafficMachine(); }

  getInitialState(): TrafficState { return { 'variant': 'red' }; }

  reduce(state: TrafficState, event: TrafficEvent): FsmStepType<TrafficState, TrafficEffect> {
    if (event.type === 'advance') {
      if (state.variant === 'red')   { return { 'effects': [], 'state': { 'variant': 'green' } }; }
      if (state.variant === 'green') { return { 'effects': [{ 'tone': 'chime', 'variant': 'playSound' }], 'state': { 'variant': 'amber' } }; }
      if (state.variant === 'amber') { return { 'effects': [], 'state': { 'variant': 'red' } }; }
    }
    return { 'effects': [], 'state': state };
  }

  protected override onTransition(from: TrafficState, to: TrafficState, event: TrafficEvent): void {
    console.log(`[fsm:machine] transition  ${from.variant} --[${event.type}]--> ${to.variant}`);
  }

  protected override onEnterState(state: TrafficState): void {
    console.log(`[fsm:machine] enter       state=${state.variant}`);
  }

  protected override onExitState(state: TrafficState): void {
    console.log(`[fsm:machine] exit        state=${state.variant}`);
  }

  protected override onTransitionRejected(state: TrafficState, event: TrafficEvent, reason: string): void {
    console.log(`[fsm:machine] rejected    state=${state.variant} event=${event.type} reason=${reason}`);
  }
}

// --- Observed EffectInterpreter subclass ---

class ObservedInterpreter extends EffectInterpreter<TrafficState, TrafficEvent, TrafficEffect> {
  static makeObserved(
    machine: ObservedTrafficMachine,
    handlers: EffectHandlerMapType<TrafficEffect>
  ): ObservedInterpreter {
    return new ObservedInterpreter({ 'handlers': handlers, 'machine': machine, 'machineId': 'traffic-light' });
  }

  protected override onStart(state: TrafficState): void {
    console.log(`[fsm:interp]  start       initialState=${state.variant}`);
  }

  protected override onStop(state: TrafficState | undefined): void {
    console.log(`[fsm:interp]  stop        lastState=${state?.variant ?? 'unknown'}`);
  }

  protected override onEnqueue(event: TrafficEvent): void {
    console.log(`[fsm:interp]  enqueue     event=${event.type}`);
  }

  protected override onTransition(from: TrafficState, to: TrafficState, event: TrafficEvent): void {
    console.log(`[fsm:interp]  transition  ${from.variant} --[${event.type}]--> ${to.variant}`);
  }

  protected override onEnterState(state: TrafficState): void {
    console.log(`[fsm:interp]  enter       state=${state.variant}`);
  }

  protected override onExitState(state: TrafficState): void {
    console.log(`[fsm:interp]  exit        state=${state.variant}`);
  }

  protected override onEffectStart(effect: TrafficEffect): void {
    console.log(`[fsm:interp]  effectStart variant=${effect.variant} tone=${effect.tone}`);
  }

  protected override onEffectSuccess(effect: TrafficEffect): void {
    console.log(`[fsm:interp]  effectOk    variant=${effect.variant}`);
  }

  protected override onEffectError(effect: TrafficEffect, error: Error): void {
    console.log(`[fsm:interp]  effectError variant=${effect.variant} error=${error.message}`);
  }
}

// --- Observed MachineRegistry subclass ---

class ObservedRegistry extends MachineRegistry {
  static override create(): ObservedRegistry {
    return new ObservedRegistry();
  }

  protected override onRegister(id: string): void {
    console.log(`[fsm:registry] register   id=${id}`);
  }

  protected override onUnregister(id: string): void {
    console.log(`[fsm:registry] unregister id=${id}`);
  }

  protected override onResolveMiss(id: string): void {
    console.log(`[fsm:registry] miss       id=${id}`);
  }
}

// --- Scenario: drive the traffic light through a full cycle ---

const soundsPlayed: string[] = [];
const handlers: EffectHandlerMapType<TrafficEffect> = {
  'playSound': (effect) => { soundsPlayed.push(effect.tone); }
};

const machine = ObservedTrafficMachine.make();
const interpreter = ObservedInterpreter.makeObserved(machine, handlers);

const observedRegistry = ObservedRegistry.create();

console.log('\n--- Registering interpreter ---');
observedRegistry.register('traffic-light', interpreter as Parameters<typeof observedRegistry.register>[1]);

console.log('\n--- Starting interpreter ---');
interpreter.start();

console.log('\n--- Sending events: advance × 3 ---');
await interpreter.send({ 'type': 'advance' }); // red → green
await interpreter.send({ 'type': 'advance' }); // green → amber (plays sound)
await interpreter.send({ 'type': 'advance' }); // amber → red

console.log('\n--- Probing a missing registry key ---');
observedRegistry.get('no-such-machine');

console.log('\n--- Stopping interpreter ---');
interpreter.stop();

console.log('\n--- Unregistering ---');
observedRegistry.unregister('traffic-light');

console.log('\nFinal state:', interpreter.getState().variant);
console.log('Sounds played:', soundsPlayed);
// #endregion usage

assert.equal(interpreter.getState().variant, 'red');
assert.deepEqual(soundsPlayed, ['chime']);

assert.ok(true, 'observed interpreter exercised onStart');
assert.ok(true, 'observed interpreter exercised onStop');

console.log('\nobservedFsm: all assertions passed');
Output
Press Execute to run this example against the real library.

Source on GitHub