@studnicky/fsm
Type-safe abstract FSM base class with async effect interpretation and named machine registry.
Install
pnpm add @studnicky/fsmRequires @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:
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:
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():
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
| Hook | When it fires | Args |
|---|---|---|
onTransition(from, to, event) | After a successful state-variant change, before the step is returned | from: 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 failed | state: 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
| Hook | When it fires | Args |
|---|---|---|
onStart(state) | After start() sets the initial state | state: TState |
onStop(state) | After stop() halts event processing | state: 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 change | from: TState, to: TState, event: TEvent |
onEnterState(state) | After committing a new state variant | state: TState |
onExitState(state) | Before committing the new state, while still in the old variant | state: TState |
onEffectStart(effect) | Before invoking an effect handler | effect: TEffect |
onEffectSuccess(effect) | After an effect handler resolves successfully | effect: TEffect |
onEffectError(effect, error) | When an effect handler throws | effect: 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.
| Hook | When it fires | Args |
|---|---|---|
onRegister(id) | After a named interpreter is successfully registered | id: 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 id | id: string |
Example — traced traffic light
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.
| Export | Type | Description |
|---|---|---|
StateMachine<TState, TEvent, TEffect> | abstract class | Base FSM; implement getInitialState and reduce |
EffectInterpreter<TState, TEvent, TEffect> | class | Drives a machine; configure a singular handler through create({ machine, handler }) |
InterpreterHistory<TState, TEvent, TEffect> | class | Bounded recorder of one interpreter's variant-changing transitions |
MachineRegistry<TState, TEvent> | class | Instantiable named registry of interpreters |
FsmStepInterface<TState, TEffect> | interface | Readonly { state, effects } contract returned by reduce |
FsmTransitionInterface<TState, TEvent, TEffect> | interface | Callable contract for standalone transition functions |
EffectHandlerInterface<TEffect, TEvent> | interface | Singular callable effect handler with an in-drain dispatch(event) capability |
EffectInterpreterConstructorOptionsInterface<TState, TEvent, TEffect> | interface | Parameter contract for EffectInterpreter's protected constructor; annotate a subclass constructor's parameter with it |
InterpreterHistoryRecordInterface<TState, TEvent> | interface | Readonly transition-history record contract |
RegisteredInterpreterInterface<TState, TEvent> | interface | Interpreter contract accepted by MachineRegistry |
InterpreterHistoryRecordMetadataEntity | namespace | Schema-derived transition-record timestamp contract |
RegisteredInterpreterMetricsEntity | namespace | Schema-derived hook-error count contract |
FsmError and package errors | classes | FsmConfigError, interpreter lifecycle errors, mailbox capacity errors, registry errors, reducer defects, termination, and rejected transitions |
StateMachine<TState, TEvent, TEffect>
| Member | Signature | Description |
|---|---|---|
getInitialState | () => TState | Returns 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>
| Member | Signature | Description |
|---|---|---|
start | () => void | Initialises state; must be called before send |
stop | () => void | Halts event processing |
getState | () => TState | Returns current state; throws if not started |
send | (event: TEvent) => Promise<void> | Enqueues event and drains mailbox |
subscribe | (observer) => () => void | Registers 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:
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.
Entities
@studnicky/fsm/entities exports interpreter history and registry metrics schemas.
import { InterpreterHistoryRecordMetadataEntity } from '@studnicky/fsm/entities';Exports
| Symbol | Purpose | Import path |
|---|---|---|
EffectHandlerInterface | Defines effect execution callbacks. | @studnicky/fsm |
EffectInterpreter | Executes state-machine effects. | @studnicky/fsm |
EffectInterpreterConstructorOptionsInterface | Defines interpreter construction options. | @studnicky/fsm |
FsmConfigError | Represents invalid FSM configuration. | @studnicky/fsm |
FsmError | Base error for FSM failures. | @studnicky/fsm |
FsmStepInterface | Defines a state-machine transition result. | @studnicky/fsm |
FsmTransitionInterface | Defines a state-machine transition. | @studnicky/fsm |
InterpreterHistory | Retains state-machine transition history. | @studnicky/fsm |
InterpreterHistoryCreateOptionsInterface | Defines bounded transition-history construction options. | @studnicky/fsm |
InterpreterHistoryRecordInterface | Defines a recorded transition. | @studnicky/fsm |
InterpreterNotRunningError | Signals work submitted to a stopped interpreter. | @studnicky/fsm |
InterpreterNotStartedError | Signals work submitted before an interpreter starts. | @studnicky/fsm |
MachineAlreadyRegisteredError | Signals duplicate machine registration. | @studnicky/fsm |
MachineRegistry | Manages named state-machine interpreters. | @studnicky/fsm |
MachineTerminatedError | Signals use of a terminated machine. | @studnicky/fsm |
MailboxCapacityExceededError | Signals an interpreter mailbox overflow. | @studnicky/fsm |
ReducerThrewError | Wraps an error thrown by a reducer. | @studnicky/fsm |
RegisteredInterpreterInterface | Defines a registered interpreter entry. | @studnicky/fsm |
StateMachine | Defines typed state transitions and effects. | @studnicky/fsm |
TransitionRejectedError | Signals a rejected state transition. | @studnicky/fsm |