Flat extended finite state machines in TypeScript with a typed builder, declaration-order transition selection, and isolated drafts for speculative execution. It fits event-driven logic with one explicit current state, one explicit context value, and predictable dispatch rules.
Install the package and its peer dependencies:
pnpm add @escapace/fsmimport { interpret, stateMachine } from '@escapace/fsm'
type Coin = 5 | 10 | 25 | 50
enum State {
Locked = 'LOCKED',
Unlocked = 'UNLOCKED',
}
enum Action {
Coin = 'COIN',
Push = 'PUSH',
}
const machine = stateMachine()
.state(State.Locked)
.state(State.Unlocked)
.initial(State.Locked)
.action<Action.Coin, { amount: Coin }>(Action.Coin)
.action(Action.Push)
.context(() => ({ balance: 0 }))
.transition(
State.Locked,
[Action.Coin, (context, action) => context.balance + action.payload.amount >= 50],
State.Unlocked,
(context, action) => ({ balance: context.balance + action.payload.amount - 50 }),
)
.transition(State.Locked, Action.Coin, State.Locked, (context, action) => ({
balance: context.balance + action.payload.amount,
}))
.transition(State.Unlocked, Action.Coin, State.Unlocked)
.transition(State.Unlocked, Action.Push, State.Locked, () => ({ balance: 0 }))
.done()
const service = interpret(machine)
service.do(Action.Coin, { amount: 25 }) // true
console.log(service.state) // 'LOCKED'
console.log(service.context) // { balance: 25 }
service.do(Action.Push) // false
console.log(service.state) // 'LOCKED'
service.do(Action.Coin, { amount: 25 }) // true
console.log(service.state) // 'UNLOCKED'
console.log(service.context) // { balance: 0 }A machine definition has two phases:
- build it with the fluent
stateMachine()builder - finalize it with
.done() - pass the finalized definition to
interpret(...)
Finalization produces an immutable definition and precomputes runtime lookup structures once.
interpret(machine, { hydrate: { state, context } }) starts from a previously saved snapshot instead of the machine initial state and context factory.
const resumed = interpret(machine, {
hydrate: {
context: { balance: 25 },
state: State.Locked,
},
})A running service exposes state, context, do(action, payload?), draft(), and subscribe(callback).
Standalone interpretation requires a finalized definition with an initial state. Composition requires finalized child definitions. Composition does not add runtime hierarchy; after .compose(...) the machine is still flat.
Hydration restores a service from an explicit startup snapshot.
Use it when runtime state has already been persisted and startup should resume from that snapshot instead of rebuilding from the machine definition.
Rules:
- hydration bypasses context factory execution
hydratemust be an object withstateandcontextkeys- hydrated
statemust be a declared state - if hydrated context has a
statediscriminant, it must match hydratedstate - startup does not deep-clone hydrated context; pass a fresh object per service when shared references would be a problem
hydrate: undefinedbehaves the same as omitting hydration- prototype-inherited
hydrateis ignored; hydration must be provided as an own property on the options object
Context policy is for machines that need custom draft-copy behavior, custom replay publication behavior, or both. It is defined on the finalized machine so behavior is portable with the machine definition.
const machine = stateMachine()
// ...states/actions/transitions
.done({
snapshotContext: (context) => ({ ...context }),
reconcileContext: (parentContext, nextContext) => {
Object.assign(parentContext, nextContext)
return parentContext
},
})
const service = interpret(machine)Policy execution points:
service.do(...)anddraft.do(...):reconcileContextis not applied; dispatch remains reducer-drivendraft()and nesteddraft.draft():snapshotContextcreates the new draft baselinecommit()replay (draft -> parent): parentreconcileContextpublishes each replayed step
- input is the source context at draft creation
- return value becomes the new draft baseline
- returned value should keep draft writes isolated from the source context graph
- returned value should match machine context shape
- for composed machines, returned value should preserve composed group slices (
context[group])
For composed machines, invalid output shape is an invalid contract. Behavior is undefined and runtime does not add shape validation.
- inputs are current published context and replay step result
- return value becomes the published context for that replayed step
- in-place publication (
mutate parentContext and return it) and replacement publication (return new value) are both supported - function runs only during replay publication, not during live
service.do(...)
For child-owned replay steps, publication order is deterministic:
- child slice policy runs first
- parent policy runs second
- parent
reconcileContextcannot replace the child group slice by returning a differentcontext[group]; child-produced slice is kept for that step
Parent policy should treat child slices as read-only during child-owned replay. Runtime does not enforce this.
Most valid operations report ordinary outcomes through return values instead of exceptions.
service.do(...)anddraft.do(...)returntrueon a selected transition andfalsewhen a valid dispatch does not select onesubscribe(...)returns an unsubscribe functiondraft.commit()anddraft.discard()return normally on success, including an empty-trace commit- thrown
StateMachineErrorvalues indicate malformed definitions, hydration shape mismatches, undeclared actions, closed drafts, or conflicting draft commits - exceptions thrown by user guards or reducers are propagated as-is (they are not wrapped as
StateMachineError)
A false dispatch always means the machine did not advance. State, context, and subscriptions remain unchanged.
This split keeps no-op machine outcomes ordinary and keeps control-flow errors explicit: contract/lifecycle errors use StateMachineError, while user guard/reducer exceptions propagate unchanged.
For valid dispatches, transition selection follows one rule set:
- candidates are selected by current state and dispatched action
- candidates are tried in declaration order
- guards inside one candidate run left to right and stop at the first
false - the first candidate whose guards all pass is selected
- only the selected candidate's reducer runs
Source and target arrays in .transition(...) expand as the Cartesian product of sources and targets.
Subscriptions observe successful live transitions only. Callbacks receive post-transition state, context, and action. Identical callback functions are deduplicated. The change object is reused across notifications, so retained values should be copied inside the callback.
If a guard or reducer throws, the failing step is not published and does not advance machine state for that step. However, side effects inside user guard/reducer code are not rolled back.
This is primarily a type-level ergonomics feature.
When context is a union discriminated by state, guards narrow to source-state variants, reducers narrow from source state to target state, and subscription changes narrow to the transition result. In practice that removes most manual casts and turns wrong target variants into type errors.
The runtime layer is smaller. It validates the initial context discriminant at interpret(...) time and keeps context.state synchronized after successful live dispatch, draft replay, and composed child updates.
enum PinInputState {
Idle = 'IDLE',
Focused = 'FOCUSED',
Completed = 'COMPLETED',
Error = 'ERROR',
}
enum PinInputAction {
Focus = 'FOCUS',
Input = 'INPUT',
}
type PinInputContext =
| { state: PinInputState.Idle; focusedIndex: -1; values: string[] }
| { state: PinInputState.Focused; focusedIndex: number; values: string[] }
| { state: PinInputState.Completed; focusedIndex: number; values: string[] }
| {
state: PinInputState.Error
error: string
focusedIndex: number
values: string[]
}
const machine = stateMachine()
.state(PinInputState.Idle)
.state(PinInputState.Focused)
.state(PinInputState.Completed)
.state(PinInputState.Error)
.initial(PinInputState.Idle)
.action<PinInputAction.Focus, { index: number }>(PinInputAction.Focus)
.action<PinInputAction.Input, { index: number; value: string }>(PinInputAction.Input)
.context<PinInputContext>(() => ({
focusedIndex: -1 as const,
state: PinInputState.Idle as const,
values: ['', '', '', ''],
}))
.transition(
PinInputState.Idle,
PinInputAction.Focus,
PinInputState.Focused,
(context, action) => ({
focusedIndex: action.payload.index,
state: PinInputState.Focused as const,
values: context.values,
}),
)
.transition(
PinInputState.Focused,
[PinInputAction.Input, (_context, action) => /^\d$/.test(action.payload.value)],
PinInputState.Completed,
(context, action) => ({
focusedIndex: action.payload.index,
state: PinInputState.Completed as const,
values: context.values.map((entry, index) =>
index === action.payload.index ? action.payload.value : entry,
),
}),
)
.transition(
PinInputState.Focused,
[PinInputAction.Input, (_context, action) => !/^\d$/.test(action.payload.value)],
PinInputState.Error,
(context, action) => ({
error: `Input must be numeric: ${action.payload.value}`,
focusedIndex: context.focusedIndex,
state: PinInputState.Error as const,
values: context.values,
}),
)The example is abridged, but it shows the main rules:
- reducer input narrows by source state and reducer output narrows by target state
- returning the wrong variant for a target state is a type error
- subscription changes keep
change.state,change.action.target, andchange.contextaligned to the same transition result change.stateandchange.action.targetare the stable discriminators when one action can reach multiple targetsinterpret(...)validates that an initial context discriminant matches the machine initial state- successful transitions synchronize
context.state - automatic synchronization updates only the
statediscriminant field; every other field remains reducer-defined
For enum-based states, the context discriminant should use enum member types rather than raw string literals.
Flat object contexts and primitive contexts remain valid. When no state field exists, runtime state injection is skipped.
service.draft() creates an isolated draft handle from the current live snapshot. draft.do(...) uses the same action validation, candidate selection, guard evaluation, and reducer semantics as live dispatch, but successful steps stay private until commit.
Draft behavior:
draft.do(...)returnstrue,false, or throws for the same reasons as service dispatch- a
falsedraft dispatch leaves the draft snapshot unchanged draft.status()returns'open','stale', or'closed'as an advisory view of the draft's current lifecycle conditiondraft.status()is non-authoritative;commit()still decides the real outcome and may still fail after an'open'result if the parent runtime advances first- drafts expose
draft.subscribe(...); successful localdraft.do(...)calls notify only that draft draft.discard()closes the handle and drops speculative workdraft.draft()creates a nested draft from the current draft snapshot- child
commit()publishes to the immediate parent draft in replay order and notifies parent draft subscribers once per published step - nested publication is one boundary at a time; a child commit does not notify grandparent drafts or the service directly
- root
commit()publishes to the live service in replay order and notifies service subscribers once per replayed step - during commit replay, the receiving runtime (
parentdraft or service) is advanced step by step before each callback - reducer functions execute again at each publication boundary (
do(...)and each upwardcommit()replay) - empty-trace
commit()is a no-op that still closes the draft - after
commit()ordiscard(), mutating draft methods throwDraftClosed - commit and discard close the draft observation channel and recursively release descendant draft subscriptions
- conflicting commits are rejected with
DraftCommitConflict
Draft snapshots support primitives, arrays, ordinary objects, Date, Map, Set, ArrayBuffer, DataView, typed arrays, functions (preserved by reference), cycles, and shared references.
.compose(group, child) merges a finalized child machine into the parent definition while mounting child context under context[group].
const child = stateMachine()
.state('On')
.state('Off')
.action('Toggle')
.transition('On', 'Toggle', 'Off')
.transition('Off', 'Toggle', 'On')
.done()
const parent = stateMachine()
.state('Idle')
.compose('power', child)
.initial('Idle')
.action('Start')
.transition('Idle', 'Start', 'On')
.done()Composition stays flat at runtime.
- group names are reserved context keys only
- group names are not states and cannot be transition targets
- parent and child states must be disjoint
- composed siblings cannot share action names
- a parent and child may share an action name when payload types are compatible
- child guards and reducers operate on the child context slice only
- parent and sibling context slices are preserved during child updates
- parent context factories must not define keys that collide with composed group names
.context(...).compose(...)and.compose(...).context(...)produce the same compound context shape- a child used only through composition may omit its own initial state when transitions target explicit child states
All library-thrown contract and lifecycle errors are StateMachineError instances. The human-readable message is paired with a structured cause.type that can be inspected programmatically.
Exceptions thrown inside user guards or reducers are propagated unchanged.
| Error | Raised when |
|---|---|
ActionAlreadyDeclared |
.action(...) declares an action that is already declared in the current machine. |
ActionConflict |
.compose(...) introduces an action that conflicts with an action from a previously composed sibling. |
ActionNotDeclared |
a transition references an undeclared action, or do(...) / draft.do(...) dispatches an undeclared action. |
ContextInitializerExpected |
a context initializer is not a function with no arguments. |
ContextStateMismatch |
startup context has a state discriminant that does not match the startup state. |
ContextGroupConflict |
a parent context factory returns an own key that conflicts with a composed group name. |
DraftClosed |
do(...), draft(), commit(), or discard() is called on a closed draft or below a closed ancestor. |
DraftCommitConflict |
commit() runs after the live service or parent draft has advanced since draft creation. |
GroupNameConflict |
.compose(group, child) reuses a group name, collides with a declared state, or uses a group name that matches a child state. |
HydrationShapeMismatch |
interpret(...) receives a hydrate payload that is not an object with state and context keys. |
StateMachineExpected |
interpret(...) or .compose(...) receives a value that is not a state machine definition. |
StateAlreadyDeclared |
.state(...) declares a state that is already declared, or .compose(...) introduces a child state that is already declared. |
StateNotDeclared |
.initial(...), .transition(...), or hydrated startup references a state that has not been declared. |
The package also exports isStateMachineError(...), isStateMachineErrorOfType(...), and STATE_MACHINE_ERROR_TYPES.
A few boundaries are deliberate:
falsefromdo(...)conflates two cases: no transition for(state, action)and matching transitions whose guards all fail- the service type does not narrow itself to the current runtime state; action availability remains a runtime question
- drafts snapshot context at creation time; function values are preserved by reference within the snapshot graph
- the library stays flat at runtime and does not implement hierarchy, parallel regions, or history semantics
@escapace/fsm shows about 52.49x higher throughput than xstate and about 4.34x higher throughput than yay-machine in the repository’s representative benchmark (guarded transitions with immutable context updates), indicating the remaining abstraction cost versus direct state updates. These figures come from microbenchmarks run in tight loops in a controlled single-process setup, so they measure transition-dispatch overhead rather than end-to-end application latency.