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 { EffectHandlerInterface, FsmStepInterface } from '../src/index.js';
import type { TrafficEffectEntity } from './entities/TrafficEffectEntity.js';
import type { TrafficEventEntity } from './entities/TrafficEventEntity.js';
import type { TrafficStateEntity } from './entities/TrafficStateEntity.js';

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

class TrafficLight extends StateMachine<TrafficStateEntity.Type, TrafficEventEntity.Type, TrafficEffectEntity.Type> {
  static make(): TrafficLight { return new TrafficLight(); }

  getInitialState(): TrafficStateEntity.Type {
    return { 'variant': 'red' };
  }

  reduce(
    state: TrafficStateEntity.Type,
    event: TrafficEventEntity.Type
  ): FsmStepInterface<TrafficStateEntity.Type, TrafficEffectEntity.Type> {
    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 handler: EffectHandlerInterface<TrafficEffectEntity.Type> = (effect) => {
    TrafficLightDemo.soundsPlayed.push(effect.tone);
  };

  static async run(): Promise<{ readonly 'finalVariant': TrafficStateEntity.Type['variant']; readonly 'history': string[]; readonly 'soundsPlayed': string[] }> {
    const machine: TrafficLight = TrafficLight.make();
    const interpreter: EffectInterpreter<TrafficStateEntity.Type, TrafficEventEntity.Type, TrafficEffectEntity.Type> = EffectInterpreter.create({
      'handler': TrafficLightDemo.handler,
      '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 registry

Each MachineRegistry.create() call creates an independent store for named interpreter instances. Registering under a key makes the interpreter available to code holding that registry instance:

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

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

class Toggle extends StateMachine<ToggleStateEntity.Type, ToggleEventEntity.Type> {
  static make(): Toggle { return new Toggle(); }

  getInitialState(): ToggleStateEntity.Type {
    return { 'variant': 'off' };
  }

  reduce(state: ToggleStateEntity.Type, event: ToggleEventEntity.Type): FsmStepInterface<ToggleStateEntity.Type> {
    if (event.type === 'toggle') {
      return { 'effects': [], 'state': { 'variant': state.variant === 'off' ? 'on' : 'off' } };
    }
    return { 'effects': [], 'state': state };
  }
}

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

const registry = MachineRegistry.create<ToggleStateEntity.Type, ToggleEventEntity.Type>();

// 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 rethrows TransitionRejectedError unchanged and wraps other reducer defects in ReducerThrewError. EffectInterpreter guards against reads before start() and sends after stop():

ts
import type { FsmStepInterface } 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 ---

class BrokenMachine extends StateMachine<BrokenStateEntity.Type, BrokenEventEntity.Type> {
  static make(): BrokenMachine { return new BrokenMachine(); }

  getInitialState(): BrokenStateEntity.Type {
    return { 'variant': 'active' };
  }

  reduce(_state: BrokenStateEntity.Type, event: BrokenEventEntity.Type): FsmStepInterface<BrokenStateEntity.Type> {
    if (event.type === 'boom') { throw RuntimeError.create('reducer exploded'); }
    return { 'effects': [], 'state': _state };
  }
}

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

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

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

// --- InterpreterNotStartedError ---

class SimpleMachine extends StateMachine<SimpleStateEntity.Type, SimpleEventEntity.Type> {
  static make(): SimpleMachine { return new SimpleMachine(); }
  getInitialState(): SimpleStateEntity.Type { return { 'variant': 'idle' }; }
  reduce(state: SimpleStateEntity.Type): FsmStepInterface<SimpleStateEntity.Type> { return { 'effects': [], 'state': state }; }
}

const notStarted: EffectInterpreter<SimpleStateEntity.Type, SimpleEventEntity.Type> = 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<SimpleStateEntity.Type, SimpleEventEntity.Type> = 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 exposes protected instance hooks. Override them in a subclass and call register, unregister, and get on that registry instance.

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 { EffectHandlerInterface, FsmStepInterface } from '../src/index.js';
import type { TrafficEffectEntity } from './entities/TrafficEffectEntity.js';
import type { TrafficEventEntity } from './entities/TrafficEventEntity.js';
import type { TrafficStateEntity } from './entities/TrafficStateEntity.js';

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

// --- Observed StateMachine subclass ---

class ObservedTrafficMachine extends StateMachine<TrafficStateEntity.Type, TrafficEventEntity.Type, TrafficEffectEntity.Type> {
  static make(): ObservedTrafficMachine { return new ObservedTrafficMachine(); }

  getInitialState(): TrafficStateEntity.Type { return { 'variant': 'red' }; }

  reduce(
    state: TrafficStateEntity.Type,
    event: TrafficEventEntity.Type
  ): FsmStepInterface<TrafficStateEntity.Type, TrafficEffectEntity.Type> {
    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: TrafficStateEntity.Type, to: TrafficStateEntity.Type, event: TrafficEventEntity.Type): void {
    console.log(`[fsm:machine] transition  ${from.variant} --[${event.type}]--> ${to.variant}`);
  }

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

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

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

// --- Observed EffectInterpreter subclass ---

class ObservedInterpreter extends EffectInterpreter<TrafficStateEntity.Type, TrafficEventEntity.Type, TrafficEffectEntity.Type> {
  static readonly handler: EffectHandlerInterface<TrafficEffectEntity.Type> = (effect) => {
    soundsPlayed.push(effect.tone);
  };

  static makeObserved(
    machine: ObservedTrafficMachine,
    handler: EffectHandlerInterface<TrafficEffectEntity.Type>
  ): ObservedInterpreter {
    return new ObservedInterpreter({ 'handler': handler, 'machine': machine, 'machineId': 'traffic-light' });
  }

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

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

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

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

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

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

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

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

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

// --- Observed MachineRegistry subclass ---

class ObservedRegistry extends MachineRegistry<TrafficStateEntity.Type, TrafficEventEntity.Type> {
  static make(): 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 machine = ObservedTrafficMachine.make();
const interpreter = ObservedInterpreter.makeObserved(machine, ObservedInterpreter.handler);

const observedRegistry = ObservedRegistry.make();

console.log('\n--- Registering interpreter ---');
observedRegistry.register('traffic-light', interpreter);

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

Import FSM classes, root-level type contracts, and package errors from @studnicky/fsm; import schema namespaces from @studnicky/fsm/entities.

ExportTypeDescription
StateMachine<TState, TEvent, TEffect>abstract classBase FSM; implement getInitialState and reduce
EffectInterpreter<TState, TEvent, TEffect>classDrives a machine; configure a singular handler through create({ machine, handler })
InterpreterHistory<TState, TEvent, TEffect>classBounded recorder of one interpreter's variant-changing transitions
MachineRegistry<TState, TEvent>classInstantiable named registry of interpreters
FsmStepInterface<TState, TEffect>interfaceReadonly { state, effects } contract returned by reduce
FsmTransitionInterface<TState, TEvent, TEffect>interfaceCallable contract for standalone transition functions
EffectHandlerInterface<TEffect, TEvent>interfaceSingular callable effect handler with an in-drain dispatch(event) capability
EffectInterpreterConstructorOptionsInterface<TState, TEvent, TEffect>interfaceParameter contract for EffectInterpreter's protected constructor; annotate a subclass constructor's parameter with it
InterpreterHistoryRecordInterface<TState, TEvent>interfaceReadonly transition-history record contract
RegisteredInterpreterInterface<TState, TEvent>interfaceInterpreter contract accepted by MachineRegistry
InterpreterHistoryRecordMetadataEntitynamespaceSchema-derived transition-record timestamp contract
RegisteredInterpreterMetricsEntitynamespaceSchema-derived hook-error count contract
FsmError and package errorsclassesFsmConfigError, interpreter lifecycle errors, mailbox capacity errors, registry errors, reducer defects, termination, and rejected transitions

StateMachine<TState, TEvent, TEffect>

MemberSignatureDescription
getInitialState() => TStateReturns the machine's initial state
reduce(state, event) => FsmStepInterface<TState, TEffect>Pure transition function
transition(state, event) => FsmStepInterface<TState, TEffect>Calls reduce; wraps reducer defects 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

InterpreterHistory<TState, TEvent, TEffect>

InterpreterHistory is a bounded EffectInterpreter with the same optional singular handler. It records each variant-changing onTransition event and exposes readonly, isolated snapshots:

ts
import type { FsmStepInterface } from '../src/index.js';
import type { TrafficEventEntity } from './entities/TrafficEventEntity.js';
import type { TrafficStateEntity } from './entities/TrafficStateEntity.js';

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

// --- Domain types ---

class TrafficMachine extends StateMachine<TrafficStateEntity.Type, TrafficEventEntity.Type> {
  static make(): TrafficMachine { return new TrafficMachine(); }

  getInitialState(): TrafficStateEntity.Type { return { 'variant': 'red' }; }

  reduce(state: TrafficStateEntity.Type, event: TrafficEventEntity.Type): FsmStepInterface<TrafficStateEntity.Type> {
    if (event.type === 'advance') {
      if (state.variant === 'red')   { return { 'effects': [], 'state': { 'variant': 'green' } }; }
      if (state.variant === 'green') { return { 'effects': [], 'state': { 'variant': 'amber' } }; }
      if (state.variant === 'amber') { return { 'effects': [], 'state': { 'variant': 'red' } }; }
    }
    return { 'effects': [], 'state': state };
  }
}

// A bounded ring of the last 2 transitions — older records are dropped.
const history = InterpreterHistory.create({
  'capacity': 2,
  'machine': TrafficMachine.make(),
  'machineId': 'traffic-light'
});

history.start();
await history.send({ 'type': 'advance' }); // red -> green
await history.send({ 'type': 'advance' }); // green -> amber
await history.send({ 'type': 'advance' }); // amber -> red, evicts the red -> green record

console.log('Recorded transitions (oldest first):');
const historyRecords = history.history();
const historyRecordsLength = historyRecords.length;
for (let index = 0; index < historyRecordsLength; index += 1) {
  const record = historyRecords[index]!;
  console.log(`  ${record.from.variant} --[${record.event.type}]--> ${record.to.variant} @ ${record.timestamp}`);
}

history.stop();

Each InterpreterHistoryRecordInterface<TState, TEvent> contains event, from, to, and timestamp. history() returns a fresh oldest-first snapshot. The internal ring retains at most capacity records and evicts the oldest when full. Successful sends that retain the current state variant are absent because the recorder follows EffectInterpreter.onTransition semantics.

The record timestamp composes from InterpreterHistoryRecordMetadataEntity, registered-interpreter metrics compose from RegisteredInterpreterMetricsEntity, and history capacity uses CircularBufferOptionsEntity.Type['capacity'] directly from @studnicky/circular-buffer.

Try it

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

Lifecycle hooks

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

Loading example…

Entities

@studnicky/fsm/entities exports interpreter history and registry metrics schemas.

typescript
import { InterpreterHistoryRecordMetadataEntity } from '@studnicky/fsm/entities';

Exports

SymbolPurposeImport path
EffectHandlerInterfaceDefines effect execution callbacks.@studnicky/fsm
EffectInterpreterExecutes state-machine effects.@studnicky/fsm
EffectInterpreterConstructorOptionsInterfaceDefines interpreter construction options.@studnicky/fsm
FsmConfigErrorRepresents invalid FSM configuration.@studnicky/fsm
FsmErrorBase error for FSM failures.@studnicky/fsm
FsmStepInterfaceDefines a state-machine transition result.@studnicky/fsm
FsmTransitionInterfaceDefines a state-machine transition.@studnicky/fsm
InterpreterHistoryRetains state-machine transition history.@studnicky/fsm
InterpreterHistoryCreateOptionsInterfaceDefines bounded transition-history construction options.@studnicky/fsm
InterpreterHistoryRecordInterfaceDefines a recorded transition.@studnicky/fsm
InterpreterNotRunningErrorSignals work submitted to a stopped interpreter.@studnicky/fsm
InterpreterNotStartedErrorSignals work submitted before an interpreter starts.@studnicky/fsm
MachineAlreadyRegisteredErrorSignals duplicate machine registration.@studnicky/fsm
MachineRegistryManages named state-machine interpreters.@studnicky/fsm
MachineTerminatedErrorSignals use of a terminated machine.@studnicky/fsm
MailboxCapacityExceededErrorSignals an interpreter mailbox overflow.@studnicky/fsm
ReducerThrewErrorWraps an error thrown by a reducer.@studnicky/fsm
RegisteredInterpreterInterfaceDefines a registered interpreter entry.@studnicky/fsm
StateMachineDefines typed state transitions and effects.@studnicky/fsm
TransitionRejectedErrorSignals a rejected state transition.@studnicky/fsm

Source on GitHub