Reference
ihsm Reference Manual
ihsm is a zero-dependency hierarchical state machine library for TypeScript and JavaScript. States are classes, events are methods, hierarchy is inheritance, and the runtime is an actor with a serialized mailbox.
Lineage: Harel’s hierarchical statecharts, encoded the Samek/QP way (class
hierarchy + explicit transitions), with cached LCA transition paths and a
typed call() request/response channel.
| Attribute | Value |
|---|---|
| Production dependencies | 0 |
| Runtime test coverage | 100% (statements, branches, functions, lines) |
| Node.js | 22+ |
Documentation: Reference · API
Introduction
ihsm targets TypeScript developers who model domain logic as classes rather
than JSON statecharts. You get hierarchical states via inheritance, typed events
via Protocol, and actor-style messaging with call() for typed
request/response — all in a runtime with zero npm dependencies and 100%
test coverage.
When to choose ihsm: backend services, session actors, protocol handlers, embedded tooling — anywhere you want compile-time event typing and a minimal supply chain.
When to prefer declarative libraries (e.g. XState): visual editors, single-chart parallel regions, or deep frontend/Stately integration. See Comparison with XState.
Table of contents
- Key concepts
- Key features
- Static type checking
- Messaging: post, call, sync
- Transitions
- Tracing
- restore()
- Error model
- Async handlers
- makeHsm
- Zero dependencies
- Code coverage
- Comparison with XState
- API quick reference
1. Key concepts
State as class
Each state is a class extending TopState or a parent state class. The
active state is the prototype of a single instance object — switched with
Object.setPrototypeOf when you call transition(NextStateClass).
class DoorTop extends TopState<DoorCtx, DoorProtocol> {}
@InitialState
class Closed extends DoorTop {
open(): void {
this.transition(Open);
}
}
class Open extends DoorTop {
close(): void {
this.transition(Closed);
}
}
XState: states are nodes in a configuration object; behavior lives in
actions and invoke blocks attached to those nodes.
Context (ctx)
ctx is your domain data — counters, IDs, buffers, flags. It is owned by
the machine instance and available in every state handler as this.ctx.
Context is not the state name. State is which class is active; context is what that state knows about the world.
XState: context on the machine config, updated via assign().
Protocol
Protocol is a TypeScript interface listing:
- Events — methods
(payload...) => void | Promise<void> - Services — methods
(resolve, reject, payload...) => void | Promise<void>used withcall()
The compiler uses Protocol to type-check post('event', ...) and
call('service', ...).
Most JavaScript HSM libraries have no compile-time event vocabulary at all.
Typed libraries (XState, Robot, etc.) use string discriminators and object
payloads; none tie post / call / deferredPost argument lists and Promise
return types to ordinary TypeScript method signatures on a single Protocol
interface the way ihsm does. See
Protocol typing.
XState: event types as discriminated unions; no first-class typed
request/response on the same actor mailbox with inferred Promise<T> from a
service method signature.
Actor mailbox
Each Hsm instance has an internal job queue. post and call enqueue
work; one job runs at a time. While a handler executes, new messages are
queued, not re-entered.
This gives you actor semantics without a separate framework: hold a reference, send messages, deterministic ordering.
XState: actor.send() with interpreter; similar serialization per actor.
makeHsm
makeHsm creates machine instances:
const door = makeHsm(DoorTop, { openCount: 0 });
await door.sync(); // wait for initialization
When and why: Hello state machine
Use this pattern when behaviour depends on mode (open vs closed, idle vs busy) and you want invalid mode combinations to be impossible at compile time.
Why classes instead of flags: a single class with isOpen / isClosed booleans forces every method to re-check flags; two states can both be true in memory. One leaf state class is always active; events are methods on that class.
When to reach for makeHsm: you need actor semantics (serialized mailbox), typed post('event'), and optional tracing — not a one-off callback. For a single open/close loop, this is the smallest correct shape: DoorCtx, DoorProtocol, @InitialState, and transition() between siblings under one root.
State diagram
Full example source
Runnable code lives under 01-hello-state-machine. The listings below are the complete, commented sources used by the trace panel.
examples/01-hello-state-machine/machine.ts
/**
* Hello state machine — minimal open/closed door.
*
* Teaches: DoorCtx, DoorProtocol, TopState root, @InitialState, transition()
* between sibling states, registerStateNames, makeHsm factory.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
/** Mutable data owned by the actor for its whole lifetime. */
export interface DoorCtx {
/** How many times the door has been opened (survives open ↔ closed). */
openCount: number;
}
/** Event vocabulary — method names must match post('…') strings at compile time. */
export interface DoorProtocol {
open(): void;
close(): void;
}
/** Root state: inherits mailbox, transition(), and tracing from TopState. */
export class DoorTop extends PlaygroundTopState<DoorCtx, DoorProtocol> {}
/** Initial leaf after makeHsm + sync — door starts closed. */
@ihsm.InitialState
export class Closed extends DoorTop {
open(): void {
this.ctx.openCount += 1;
// External transition: exit Closed, enter Open (LCA = DoorTop).
this.transition(Open);
}
}
export class Open extends DoorTop {
close(): void {
this.transition(Closed);
}
}
// Last statement: register export keys as stable display names (minified builds).
ihsm.registerStateNames(self);
/** Factory used by tests, interactive panel, and application code. */
export function createDoor() {
return ihsm.makeHsm(DoorTop, { openCount: 0 });
}
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 01' for a headless check.
2. Key features
Summary table
| Feature | How in ihsm | Explicit in library? |
|---|---|---|
| Context | ctx on instance | Yes |
| Typed events | Protocol interface | Yes |
| Hierarchy | class extends | Yes |
| Initial substate | @InitialState | Yes |
| Transition | this.transition(StateClass) | Yes |
| Cached LCA path | automatic | Yes (internal) |
| Entry / exit | onEntry() / onExit() | Yes |
| Internal transition | handle event, no transition() | Implicit (by omission) |
| Guards | if in handler | Implicit (code) |
| History | ctx + restore() | Implicit (data) |
| Orthogonal regions | nest multiple Hsm instances | Composition |
post | fire-and-forget | Yes |
deferredPost | setTimeout + queue | Yes |
call | Promise + mailbox | Yes |
sync | drain queue | Yes |
restore | set state + ctx | Yes |
makeHsm | create + optional init | Yes |
| Tracing | levels + TraceWriter | Yes |
| Errors | typed error hierarchy | Yes |
| Async handlers | async methods | Yes |
Context
Mutable domain object passed as the second argument to makeHsm. Survives transitions
unless you replace it in restore().
When and why: Context
Use a dedicated context object when the machine owns mutable domain data that survives across events and transitions (counters, session fields, order totals).
Why not store everything on the state instance: ctx is created once in makeHsm and stays the same object reference; transitions swap the state class, not the bag of data. That matches UML “extended state” and keeps serialization straightforward.
When internal transitions are enough: handlers only update this.ctx and never call transition() — no exit/entry cost (see tutorial 07). This example stays in one state class while incrementing and resetting value.
State diagram
Full example source
Runnable code lives under 03-context. The listings below are the complete, commented sources used by the trace panel.
examples/03-context/machine.ts
/**
* Context example — mutate ctx without changing active state class.
*
* Teaches: ctx survives transitions; internal transitions skip onEntry/onExit.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
export interface CounterCtx {
value: number;
/** Step size for increment/decrement — also stored in ctx, not on the class. */
step: number;
}
export interface CounterProtocol {
increment(): void;
decrement(): void;
reset(): void;
}
export class CounterTop extends PlaygroundTopState<CounterCtx, CounterProtocol> {
increment(): void {
this.ctx.value += this.ctx.step;
// No transition() → internal transition; Running stays active.
}
decrement(): void {
this.ctx.value -= this.ctx.step;
}
reset(): void {
this.ctx.value = 0;
}
}
@ihsm.InitialState
export class Running extends CounterTop {}
ihsm.registerStateNames(self);
export function createCounter(initial = 0, step = 1) {
return ihsm.makeHsm(CounterTop, { value: initial, step });
}
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 03' for a headless check.
Protocol
Declares the vocabulary of the machine. Event names must match method names on state classes (or inherited from parents). The typing strategy — events vs services, payload inference, reserved names — is documented in Protocol typing.
Hierarchical states
Child states extend parent states. The prototype chain defines the state tree.
Entering a composite runs onEntry from outer to inner initial leaf; exiting
walks up the LCA path.
@InitialState
Decorator function marking the default child of a composite:
@InitialState
class CheckingInventory extends Active { }
Only one initial state per parent; duplicate marks throw InitialStateError.
Transitions and caching
Calling this.transition(Destination) schedules a transition after the current
handler finishes. The runtime computes the lowest common ancestor path,
runs onExit up from the current leaf, then onEntry down to the target (via
initial substates if entering a composite).
Transition paths are cached keyed by FromState=>ToState for hot loops.
Entry and exit
Override onEntry() / onExit() on state classes. Sync or async. Only states
that define their own handlers participate in debug/trace exit lists; inherited
empty defaults from TopState are skipped in verbose tracing.
Internal transitions
If the handler does not call transition(), the active state class
unchanged and no exit/entry runs. Updating this.ctx alone is an internal
transition.
When and why: Internal transitions
Use internal transitions when the state mode is unchanged but domain data updates — dimming a lamp, ticking a counter, appending to a log.
Why avoid a self-loop transition(SameState): exit and entry would run again (onEntry fires, entryCount increments). Omitting transition() keeps the same leaf class and skips lifecycle hooks — faster and closer to UML internal transitions.
When you still need onEntry: setup when entering a mode (run once). Brighten/dim here only touch ctx.brightness.
State diagram
Full example source
Runnable code lives under 07-internal-transitions. The listings below are the complete, commented sources used by the trace panel.
examples/07-internal-transitions/machine.ts
/**
* Internal transitions — update ctx without transition(); onEntry does not re-run.
*
* Compare entryCount: it only increments when entering On, not on dim/brighten.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
export interface LampCtx {
brightness: number;
/** Increments only on onEntry — proves exit/entry did not run on dim/brighten. */
entryCount: number;
}
export interface LampProtocol {
dim(delta: number): void;
brighten(delta: number): void;
}
export class LampTop extends PlaygroundTopState<LampCtx, LampProtocol> {
onEntry(): void {
this.ctx.entryCount += 1;
}
dim(delta: number): void {
this.ctx.brightness = Math.max(0, this.ctx.brightness - delta);
// Internal transition: no this.transition() → stay in On, no onEntry.
}
brighten(delta: number): void {
this.ctx.brightness = Math.min(100, this.ctx.brightness + delta);
}
}
@ihsm.InitialState
export class On extends LampTop {}
ihsm.registerStateNames(self);
export function createLamp(brightness: number) {
return ihsm.makeHsm(LampTop, { brightness, entryCount: 0 });
}
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 07' for a headless check.
Guards
Use ordinary TypeScript:
approve(amount: number): void {
if (amount > this.ctx.limit) {
this.transition(Rejected);
return;
}
this.transition(Approved);
}
XState: declarative guard functions on transition arrays.
History
Store “where we were” in ctx, or call restore(stateClass, ctx) to rehydrate.
No shallow/deep history pseudostates — you keep explicit control.
Orthogonal regions
Run multiple machines and coordinate with post / call between instances.
Each region has its own queue and cache.
When and why: Nested machines (orthogonal regions)
Use multiple Hsm instances when two concerns evolve independently — payment vs shipping — but your app coordinates them. This is ihsm’s answer to orthogonal regions: one queue per actor, explicit messaging between them.
Why not one giant hierarchy: coupling unrelated lifecycles into one tree forces artificial LCA transitions. Two machines stay simple; OrderCoordinator posts to each and sync()s.
When to merge into one machine: true shared parent state and a single mailbox ordering requirement across both concerns.
State diagram
Full example source
Runnable code lives under 14-nested-machines. The listings below are the complete, commented sources used by the trace panel.
examples/14-nested-machines/machine.ts
/**
* Orthogonal regions — two Hsm actors (payment + shipping) coordinated by OrderCoordinator.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
/** Payment region — own mailbox and transition cache. */
export interface PaymentCtx {
paid: boolean;
}
export interface PaymentProtocol {
markPaid(): void;
}
export class PaymentTop extends PlaygroundTopState<PaymentCtx, PaymentProtocol> {
markPaid(): void {
this.ctx.paid = true;
this.transition(PaymentDone);
}
}
@ihsm.InitialState
export class PaymentPending extends PaymentTop {}
export class PaymentDone extends PaymentTop {}
/** Shipping region — independent lifecycle from payment. */
export interface ShippingCtx {
shipped: boolean;
}
export interface ShippingProtocol {
markShipped(): void;
}
export class ShippingTop extends PlaygroundTopState<ShippingCtx, ShippingProtocol> {
markShipped(): void {
this.ctx.shipped = true;
this.transition(ShippingDone);
}
}
@ihsm.InitialState
export class ShippingWaiting extends ShippingTop {}
export class ShippingDone extends ShippingTop {}
/** Coordinator — not an Hsm; owns two actors and sequences post/sync between them. */
export class OrderCoordinator {
readonly payment: ihsm.Hsm<PaymentCtx, PaymentProtocol>;
readonly shipping: ihsm.Hsm<ShippingCtx, ShippingProtocol>;
constructor() {
this.payment = ihsm.makeHsm(PaymentTop, { paid: false });
this.shipping = ihsm.makeHsm(ShippingTop, { shipped: false });
}
async sync(): Promise<void> {
await this.payment.sync();
await this.shipping.sync();
}
async fulfill(): Promise<void> {
this.payment.post('markPaid');
await this.payment.sync();
this.shipping.post('markShipped');
await this.shipping.sync();
}
}
ihsm.registerStateNames(self);
export function createOrderCoordinator() {
return new OrderCoordinator();
}
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 14' for a headless check.
3. Static type checking
ihsm pushes correctness to compile time via generics on makeHsm,
TopState, and Hsm. At a glance:
interface PaymentProtocol {
charge(amount: number): Promise<void>;
getBalance(
resolve: (balance: number) => void,
reject: (error: Error) => void
): void;
}
class PaymentTop extends TopState<Wallet, PaymentProtocol> {}
const wallet = makeHsm(PaymentTop, { balance: 0 });
wallet.post('charge', 10); // ✓ event name + payload
// wallet.post('chargr', 10); // ✗ unknown event
// wallet.post('charge', 'ten'); // ✗ string ≠ number
const balance = await wallet.call('getBalance'); // ✓ Promise<number>
When and why: Protocol typing
Use a Protocol interface whenever callers post or call on the machine — the compiler should reject typos in event names and wrong payload types before runtime.
Why ihsm invests in generics: stringly-typed event names ('setTargt') fail in production. Binding Hsm<Context, Protocol> to your vocabulary catches mistakes at build time, including service methods with resolve/reject parameters (not passed by the client).
When to keep the protocol small: one interface per machine actor; split orthogonal concerns into multiple machines (tutorial 14) instead of one mega-protocol.
State diagram
Full example source
Runnable code lives under 04-protocol-typing. The listings below are the complete, commented sources used by the trace panel.
examples/04-protocol-typing/machine.ts
/**
* Protocol typing — compile-time checks on post() event names and payloads.
*
* Uncomment the lines at the bottom locally to see TypeScript reject typos.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
export interface ThermostatCtx {
celsius: number;
}
/** Event vocabulary — drives compile-time checks on post() and call(). */
export interface ThermostatProtocol {
setTarget(celsius: number): void;
readTarget(): number;
}
export class ThermostatTop extends PlaygroundTopState<ThermostatCtx, ThermostatProtocol> {
setTarget(celsius: number): void {
this.ctx.celsius = celsius;
}
/** Synchronous “service-like” method still typed on the protocol (not call() here). */
readTarget(): number {
return this.ctx.celsius;
}
}
@ihsm.InitialState
export class Idle extends ThermostatTop {}
ihsm.registerStateNames(self);
export function createThermostat(initialCelsius: number) {
return ihsm.makeHsm(ThermostatTop, { celsius: initialCelsius });
}
// Compile-time examples (uncomment to verify the compiler rejects mistakes):
// const t = createThermostat(20);
// t.post('setTargt', 22); // error: unknown event
// t.post('setTarget', 'hot'); // error: string not assignable to number
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 04' for a headless check.
Advanced: Protocol typing and compile-time safety
This section explains why other libraries cannot offer the same guarantees,
the typing strategy ihsm adopted, and every TypeScript mechanism used in
src/index.ts so that mistakes fail at build time instead of in production.
What other libraries do not provide
| Library / style | Event names | Payload types | call() return type | Same mailbox for events + services |
|---|---|---|---|---|
| ihsm | keyof Protocol literals | inferred from method params | Promise<T> from resolve arg | Yes |
| XState v5 | string type on objects | setup().types maps | snapshot / spawned actors / waitFor | No unified typed call |
JavaScript FSMs (e.g. vanilla switch) | runtime strings | none | callbacks / manual | N/A |
| Robot / SCXML ports | strings or enums | manual validation | ad hoc | No |
Concrete gaps elsewhere:
- Stringly-typed events —
send({ type: 'setTargt' })compiles unless you maintain a separate union and exhaustiveness checks; ihsm rejectspost('setTargt', …)because'setTargt'is notkeyof Protocol. - Untyped payloads — object events decouple payload shape from handler
signature; ihsm derives the rest parameters of
post('setTarget', …)fromProtocol['setTarget']. - No typed request/response on the actor — XState and peers use
getSnapshot(), child actors, or external promises; ihsm’scall('getBalance')returnsPromise<number>inferred from the service method’sresolvecallback. - Runtime-only vocabulary — dynamic
send(eventName, data)in untyped JS cannot catch refactors; ihsm’s vocabulary is checked when TypeScript compiles callers and when state classesimplement Protocol.
ihsm is safe at compile time because the Protocol interface is the single
source of truth for both state handler signatures and external
post / call / deferredPost call sites.
Adopted typing strategy
Five rules define how a Protocol interface maps to the runtime mailbox:
| Rule | Meaning |
|---|---|
| 1. Two type parameters everywhere | Context (domain data) and Protocol (vocabulary) flow through makeHsm, TopState, Hsm, and errors. |
| 2. Events are void handlers | A event is a Protocol method whose return type is void or Promise<void>. Payload types are everything before that return. |
| 3. Services are resolve/reject handlers | A service (for call) is a method whose first two parameters are resolve: (result: T) => void and reject: (error: Error) => void. Request args follow; Promise return type is T. |
| 4. Reserved names are excluded | Keys that exist on State (e.g. transition, post, ctx) cannot be used as event or service names — they become never at the type level. |
| 5. Untyped escape hatch | Protocol may be undefined; then post accepts string and any[] (legacy / gradual typing). |
State classes implement Protocol so handler signatures and the external
API cannot drift apart:
export interface WalletProtocol {
deposit(amount: number): void;
getBalance(resolve: ResolveCallback<number>, reject: RejectCallback): void;
}
export class WalletTop extends TopState<WalletCtx, WalletProtocol> {
deposit(amount: number): void { /* … */ }
getBalance(resolve: ResolveCallback<number>, reject: RejectCallback): void { /* … */ }
}
TypeScript features used (exhaustive)
The public API in src/index.ts implements the strategy with the following
TypeScript features. Each row links a language feature to the exported type or
signature that uses it.
1. Generic type parameters
Context and Protocol are declared once and threaded through the whole API:
export function makeHsm<Context, Protocol>(topState, ctx, initialize?, traceLevel?, traceWriter?, dispatchErrorCallback?): Hsm<Context, Protocol>
export abstract class TopState<Context = Any, Protocol extends {} | undefined = undefined> { /* … */ }
export interface Hsm<Context = Any, Protocol extends {} | undefined = undefined> { /* … */ }
Effect: makeHsm(Top, ctx) returns Hsm<Context, Protocol> — callers
inherit the same Protocol used on the state classes.
2. Generic constraints (extends)
Protocol extends {} | undefined
EventName extends keyof Protocol
Effect: Protocol must be an object type (your interface) or undefined
for untyped mode. Event names must be keys of that interface.
3. keyof and literal event names
post<EventName extends keyof Protocol>(
eventName: PostedEvent<Protocol, EventName>,
…
): void;
Effect: post('open', …) only accepts strings that exist on Protocol.
Autocomplete in the IDE lists valid event names.
4. Indexed access types
Protocol[EventName]
Used inside conditional types to read the method signature for a given event or service name.
5. Conditional types
Every helper type branches on Protocol extends undefined (untyped fallback)
and on whether a member is a valid event or service:
export type PostedEvent<Protocol, EventName extends keyof Protocol> =
Protocol extends undefined ? string
: EventName extends keyof State<any, any> ? never
: EventName;
export type EventPayload<Protocol, EventName extends keyof Protocol> =
Protocol extends undefined ? any[]
: Protocol[EventName] extends (...payload: infer Payload) => Promise<void> | void
? (Payload extends any[] ? Payload : never)
: never;
Effect:
- Unknown protocol → permissive
string/any[]. - Names on
State→never(compile error if used as event). - Non-void-return methods that are not services → payload becomes
never(usually means “not a valid event shape”; prefervoidhandlers for events).
6. infer — extract parameter tuples and return types
Event payloads — rest parameters after the event name:
Protocol[EventName] extends (...payload: infer Payload) => Promise<void> | void
? Payload
: never
For setTarget(celsius: number): void, infer Payload is [celsius: number],
so post('setTarget', 22) is valid and post('setTarget', 'hot') is not.
Service request args — everything after resolve and reject:
export type ServiceRequest<Protocol, EventName extends keyof Protocol> =
Protocol extends undefined ? any[]
: Protocol[EventName] extends (
resolve: (result: infer Reply) => void,
reject: (error: infer Error) => void,
...payload: infer Payload
) => Promise<void> | void
? (Payload extends any[] ? Payload : never)
: never;
Service response — type passed to resolve:
export type ServiceResponse<Protocol, EventName extends keyof Protocol> =
Protocol extends undefined ? any
: Protocol[EventName] extends (
resolve: infer Reply,
reject: infer Error,
...payload: infer Payload
) => Promise<void> | void
? Reply
: never;
For getBalance(resolve: (n: number) => void, reject: …): void, Reply is
number, so call('getBalance') is Promise<number>.
7. never — reject invalid names at compile time
EventName extends keyof State<any, any> ? never : EventName
If you add transition or post to Protocol, those keys collide with
State and become never, producing a type error at call sites.
Payload never also blocks wrong arity:
// Protocol: setTarget(celsius: number): void
wallet.post('setTarget'); // ✗ missing argument
wallet.post('setTarget', 1, 2); // ✗ too many arguments
8. Generic methods on interfaces and classes
Both Base and TopState declare:
post<EventName extends keyof Protocol>(
eventName: PostedEvent<Protocol, EventName>,
...eventPayload: EventPayload<Protocol, EventName>
): void;
call<EventName extends keyof Protocol>(
eventName: ServiceName<Protocol, EventName>,
...eventPayload: ServiceRequest<Protocol, EventName>
): Promise<ServiceResponse<Protocol, EventName>>;
Effect: each call site gets a specialized check for the literal event
string you pass; TypeScript narrows EventName and applies the matching
Protocol[EventName] signature.
9. Rest parameters with inferred tuples
...eventPayload: EventPayload<…> types the variadic tail of post
as an exact tuple derived from the handler, not as any[].
10. implements Protocol (optional)
TopState<Context, Protocol> already binds the protocol for makeHsm, post,
and call — you do not need implements Protocol for client typing.
Add implements Protocol only when you want an extra compile-time check that a
state class (usually the root) declares every protocol method with a compatible
signature. Handlers on child states inherit without re-implementing the interface.
11. Separate aliases for services vs events
export type ServiceName<Protocol, EventName> =
Protocol extends undefined ? string
: EventName extends keyof State<any, any> ? never
: EventName;
call() uses ServiceName + ServiceRequest / ServiceResponse;
post() uses EventName + EventPayload. Same key set,
different signature rules — a method is typed for call only if it matches the
resolve/reject pattern.
12. Typed error hierarchy
Runtime errors carry the same generics so handlers can inspect typed event names
and payloads in onError / onUnhandled:
export abstract class RuntimeError<
Context,
Protocol extends {} | undefined,
EventName extends keyof Protocol
> extends HsmError<Context, Protocol> {
eventName: PostedEvent<Protocol, EventName>;
eventPayload: EventPayload<Protocol, EventName>;
}
Effect: onError(error) inside a state can treat error.eventName and
error.eventPayload as correlated with Protocol.
13. Helper aliases for service callbacks
export type ResolveCallback<Reply> = (result: Reply) => void;
export type RejectCallback = (error: Error) => void;
These document the expected resolve/reject shapes and match what infer Reply
extracts from service methods.
Compile-time checks (summary table)
| Mistake | TypeScript error |
|---|---|
| Typo in event name | Argument of type '"setTargt"' is not assignable to parameter of type 'keyof Protocol' (or never) |
| Wrong payload type | Argument of type 'string' is not assignable to parameter of type 'number' |
| Wrong payload count | Tuple arity mismatch on rest parameters |
Calling service with post | Service-shaped method may yield never payload or wrong inference — use call |
Calling event with call | Request/response inference fails; return type may be never |
| Using reserved name | Event name resolves to never |
| Drift between handler and Protocol | Optional implements Protocol on the class that owns handlers; or wrong runtime dispatch |
End-to-end flow
- You define
ProtocolandContext. - State classes implement
Protocol(handlers). makeHsm(TopState, ctx)infersContextandProtocolfrom the top state class.- External code calls
post('event', …)/call('service', …)— TypeScript validates against the sameProtocolthe handlers implement. - At runtime, ihsm dispatches to the method on the current state prototype chain; compile-time checks ensure the vocabulary and arity are valid at every call site.
XState: strong typing via setup().types and createMachine; events remain
{ type: 'charge', amount: 10 } objects with separate type maps — not method
signatures shared with state implementations and call-style Promise inference.
4. Messaging: post, call, sync
Every messaging API has two sides:
| Side | Where | Role |
|---|---|---|
| Handler | Method on the active state class | Runs when the mailbox dispatches the event or service |
| Client | Code holding Hsm | Calls post, call, or sync — never implements the handler inline |
The Protocol interface types both: handler signatures on state classes, client call sites via post('name', …) / call('name', …).
Reading UML statecharts
this reference use PlantUML state diagrams. Map symbols to runtime behavior as follows:
| Chart element | ihsm runtime |
|---|---|
[ * ] (filled circle) | Initial pseudostate — exactly one @InitialState child per composite parent |
Rounded box / state Name { … } | State class; nested box = composite with substates |
A --> B : label | External transition — handler calls this.transition(B); LCA exit/entry runs |
StateName : event / action inside a state box | Internal transition — handler runs, no transition(), no exit/entry |
| Arrow crossing box boundary | External transition between substates or branches |
Diagram layout (PlantUML): examples use PlantUML state diagrams. To reduce overlapping transition lines when several events leave the same state:
left to right direction— default flow for most tutorial charts.- Directional arrows —
-up->,-down->,-left->,-right->(short form:-u-,-d-, …) fan arcs from one source to different targets. - Spacing —
skinparam ranksepandskinparam nodesepadd room between states. - Orthogonal lines —
skinparam linetype ortho(optional; helps some nested composites).
PlantUML still uses Graphviz auto-layout — you nudge placement with hints, not pixel-perfect control.
With left to right direction, compass keywords are interpreted before the diagram is rotated:
to place a target below the source, use -left->; above, use -right->.
Do not use self-loop arrows for internal transitions — use in-state State : event / action text instead.
After makeHsm(TopState, ctx) the runtime performs initialization: onEntry
from the top state down through each composite’s initial child until the deepest
initial leaf is active (same order as following [ * ] arrows inward).
Active state = Object.getPrototypeOf(instance).constructor — always one
leaf class in normal operation, not “parent and child simultaneously”.
Full deep-hierarchy walkthrough with trace for every transition kind: tutorial 05 and §5 Transition taxonomy.
post(event, ...payload)
Fire-and-forget. The client enqueues; the handler runs later on the active state.
Handler — event method, no resolve / reject:
// Protocol: open(): void;
@InitialState
class Closed extends DoorTop {
open(): void {
this.ctx.openCount += 1;
this.transition(Open);
}
}
Client — returns immediately; use sync() to wait for side effects:
door.post('open');
await door.sync(); // handler + transition complete
Inside a state handler, this.post('tick') schedules work after the current
handler completes (and after any transition it requested).
When and why: post and sync
Use post + sync() when the client must wait for asynchronous side effects — tests, HTTP handlers, or scripts that enqueue several events and need a single barrier.
Why post chains inside a handler defer: this.post('tick') from start() schedules work after start finishes and any transition it requested. Without sync(), the client might observe partial ctx.events.
When one sync() is enough: after a burst of posts from one handler, one marker drains the whole queue through done. After call(), you usually await the returned Promise instead.
State diagram
Full example source
Runnable code lives under 08-post-and-sync. The listings below are the complete, commented sources used by the trace panel.
examples/08-post-and-sync/machine.ts
/**
* post + sync — chained this.post from a handler; client waits with one sync().
*
* Teaches: deferred posts until handler completes; sync marker drains the queue.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
export interface QueueCtx {
/** Append-only log of handler names — order proves mailbox serialization. */
events: string[];
}
export interface QueueProtocol {
start(): void;
tick(): void;
done(): void;
}
export class QueueTop extends PlaygroundTopState<QueueCtx, QueueProtocol> {
start(): void {
this.ctx.events.push('start');
// These run after start() returns — not inline during start.
this.post('tick');
this.post('tick');
this.post('done');
}
tick(): void {
this.ctx.events.push('tick');
}
done(): void {
this.ctx.events.push('done');
}
}
@ihsm.InitialState
export class Idle extends QueueTop {}
ihsm.registerStateNames(self);
export function createQueueMachine() {
return ihsm.makeHsm(QueueTop, { events: [] });
}
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 08' for a headless check.
call(service, ...payload) — typed request/response
Unique to ihsm among common JS HSM libraries: query the same actor through its mailbox and receive a typed Promise.
Handler — first two parameters are resolve / reject (injected by runtime; client never passes them):
// Protocol: getBalance(resolve: (n: number) => void, reject: (e: Error) => void): void;
getBalance(resolve: ResolveCallback<number>, _reject: RejectCallback): void {
resolve(this.ctx.balance);
}
// async — await work, then resolve
async fetchBalance(resolve: ResolveCallback<number>, reject: RejectCallback, id: string): Promise<void> {
const row = await db.load(id);
resolve(row.balance);
}
Client — one await; no separate sync():
const balance = await wallet.call('getBalance');
The client's Promise settles when the handler calls resolve(value) or
reject(error) — not from the handler's return value alone.
Benefits:
- Same serialization guarantees as
post(no re-entrancy) - Client uses familiar
async/await - Return type inferred from
Protocol
XState: read snapshot via actor.getSnapshot(), spawn promise actors, or
use waitFor — no single typed call on the interpreter.
When and why: call services
Use call when the client needs a typed Promise result from the same actor — balance lookup, validation, or any query — while keeping mailbox serialization (no re-entrancy).
Why services use resolve/reject in the protocol: the runtime injects callbacks; the client never passes them. Sync services call resolve before return; async services await then resolve.
When to use post instead: fire-and-forget side effects where nobody awaits an outcome. Mix both on one machine: events mutate state; services answer questions.
State diagram
Full example source
Runnable code lives under 10-call-services. The listings below are the complete, commented sources used by the trace panel.
examples/10-call-services/machine.ts
/**
* call services — sync and async handlers with resolve/reject injected by runtime.
*
* Client: await wallet.call('getBalance') — no resolve/reject in the call arguments.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
export interface WalletCtx {
balance: number;
}
export interface WalletProtocol {
deposit(amount: number): void;
getBalance(resolve: ihsm.ResolveCallback<number>, reject: ihsm.RejectCallback): void;
fetchBalanceDelayed(resolve: ihsm.ResolveCallback<number>, reject: ihsm.RejectCallback, delayMs: number): Promise<void>;
withdraw(resolve: ihsm.ResolveCallback<number>, reject: ihsm.RejectCallback, amount: number): void;
}
export class WalletTop extends PlaygroundTopState<WalletCtx, WalletProtocol> {
deposit(amount: number): void {
this.ctx.balance += amount;
}
/** Sync service — call resolve (or reject) before the handler returns. */
getBalance(resolve: ihsm.ResolveCallback<number>, _reject: ihsm.RejectCallback): void {
resolve(this.ctx.balance);
}
/** Async service — return a Promise; call resolve/reject after await. */
async fetchBalanceDelayed(resolve: ihsm.ResolveCallback<number>, _reject: ihsm.RejectCallback, delayMs: number): Promise<void> {
await this.sleep(delayMs);
resolve(this.ctx.balance);
}
/** Sync service with reject — caller's Promise becomes a rejection. */
withdraw(resolve: ihsm.ResolveCallback<number>, reject: ihsm.RejectCallback, amount: number): void {
if (amount > this.ctx.balance) {
reject(new Error('insufficient funds'));
return;
}
this.ctx.balance -= amount;
resolve(this.ctx.balance);
}
}
@ihsm.InitialState
export class Open extends WalletTop {}
ihsm.registerStateNames(self);
export function createWallet(initialBalance: number) {
return ihsm.makeHsm(WalletTop, { balance: initialBalance });
}
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 10' for a headless check.
deferredPost(millis, event, ...payload)
Schedule an event after a delay via setTimeout, then enqueue normally.
Available inside handlers only (this.deferredPost).
Handler:
scheduleReminder(text: string): void {
this.deferredPost(50, 'deliver', text); // returns immediately
}
deliver(text: string): void {
this.ctx.message = text;
}
Client:
sm.post('scheduleReminder', 'hello later');
await sleep(100); // wait for timer
await sm.sync(); // wait for deliver handler
When and why: Deferred post
Use deferredPost when a handler must schedule a follow-up event after a delay without blocking the current handler — reminders, retries, or UI debouncing.
Why not setTimeout + manual post in app code: deferredPost still goes through the actor mailbox (serialized with other events) and respects the same state instance. The delay is implemented inside the runtime; you stay in the protocol vocabulary.
When to prefer explicit timers outside: cross-process scheduling or when the machine may be destroyed before the delay fires — persist a job id in ctx instead.
State diagram
Full example source
Runnable code lives under 09-deferred-post. The listings below are the complete, commented sources used by the trace panel.
examples/09-deferred-post/machine.ts
/**
* deferredPost — schedule deliver after 50ms without blocking scheduleReminder.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
export interface ReminderCtx {
message: string;
}
export interface ReminderProtocol {
scheduleReminder(text: string): void;
deliver(text: string): void;
}
export class ReminderTop extends PlaygroundTopState<ReminderCtx, ReminderProtocol> {
scheduleReminder(text: string): void {
// Returns immediately; deliver is enqueued when the timer fires.
this.deferredPost(50, 'deliver', text);
}
deliver(text: string): void {
this.ctx.message = text;
}
}
@ihsm.InitialState
export class Waiting extends ReminderTop {}
ihsm.registerStateNames(self);
export function createReminder() {
return ihsm.makeHsm(ReminderTop, { message: '' });
}
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 09' for a headless check.
sync()
Returns a Promise that resolves when a sync marker task reaches the front of the queue — client-side only (no handler to implement).
Client:
door.post('open');
await door.sync(); // through handler + its transition
sm.post('tick');
sm.post('tick');
sm.post('done');
await sm.sync(); // one sync drains all three posts
After a handler chains this.post(...) calls, call sync() again to wait
for those jobs (see the interactive example below).
Use at test boundaries and integration seams in application code.
Note: call() returns a Promise tied to the service handler; you usually
do not need a separate sync() after await call(...).
postNow(event, ...payload)
Handler-only hi-priority enqueue. After the current handler and its transition
finish, the runtime drains all hi-priority jobs before normal-priority
post work from the same turn (including this.post calls made inside the
handler).
Only available inside handlers as this.postNow(...). External clients use
ordinary post.
Use for extended transitions: several internal steps (lock, capture,
validate) that must complete before deferred side effects scheduled with
post. Multiple postNow calls run FIFO within the hi-priority queue.
See the interactive example under postNow() below.
When and why: postNow
Use postNow for extended transitions: several internal steps (lock inventory, capture payment) that must complete in order before normal post messages from the same handler — e.g. cancel posted in the same confirm() must not run until hi-priority steps finish.
Why handler-only: external clients use ordinary post; priority is a runtime scheduling rule inside one dispatch generation.
When hi-priority is overkill: a single handler body with straight-line code and no competing post from the same turn.
State diagram
Full example source
Runnable code lives under 17-post-now. The listings below are the complete, commented sources used by the trace panel.
examples/17-post-now/machine.ts
/**
* postNow — hi-priority steps before normal post from the same confirm() handler.
*
* confirm posts cancel (normal) but lock/capture run via postNow first.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
export interface CheckoutCtx {
steps: string[];
committed: boolean;
cancelled: boolean;
}
export interface CheckoutProtocol {
confirm(): void;
lockInventory(): void;
capturePayment(): void;
cancel(): void;
}
export class CheckoutTop extends PlaygroundTopState<CheckoutCtx, CheckoutProtocol> {
confirm(): void {
this.ctx.steps.push('confirm-start');
// Extended transition: critical steps must finish before any normal follow-up
// (including `cancel` posted from the same handler).
this.post('cancel');
this.postNow('lockInventory');
this.postNow('capturePayment');
this.ctx.steps.push('confirm-end');
this.transition(Confirmed);
}
lockInventory(): void {
this.ctx.steps.push('lock');
}
capturePayment(): void {
this.ctx.steps.push('capture');
this.ctx.committed = true;
}
cancel(): void {
this.ctx.steps.push('cancel');
this.ctx.cancelled = true;
}
}
export class Confirmed extends CheckoutTop {}
@ihsm.InitialState
export class Draft extends CheckoutTop {}
ihsm.registerStateNames(self);
export function createCheckout() {
return ihsm.makeHsm(CheckoutTop, {
steps: [],
committed: false,
cancelled: false,
});
}
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 17' for a headless check.
5. Transitions
this.transition(TargetStateClass);
Scheduled when the current event handler finishes successfully. ihsm computes
the lowest common ancestor (LCA) on the class prototype chain, runs onExit
from the current leaf up to (but not including) the LCA, then onEntry down
toward the target — descending @InitialState chains when the target
is a composite.
(shallow entry/exit chain and case-by-case topology).
Transition taxonomy
The table lists external transitions (handler calls transition()). An
internal transition omits transition() — only the handler body runs (see
tutorial 07).
| Kind | Example (tutorial 05) | Chart notation | Exit / entry | Notes |
|---|---|---|---|---|
| Internal | tick() in LeafWestA | LeafWestA : tick / value++ inside box | none | ctx updates; state class unchanged |
| Child → sibling child | LeafWestA → LeafWestB | A --> B : goSiblingWest | exit A, enter B | LCA = parent (MidWest) |
| Child → parent composite | LeafWestA → MidWest | arrow to parent composite | exit leaf; re-enter initial leaf | Composites with @InitialState descend again |
| Child → ancestor | LeafWestB → StackWest | arrow to ancestor | exit up to LCA; enter down initial chain | Ancestors above LCA untouched |
| Child → root | LeafWestA → DeepTop | arrow to root | exit to LCA; re-enter initial branch | Root’s own onExit/onEntry skipped at LCA |
| Cross-stack leaf → leaf | LeafWestA → LeafEastB | arrow across stacks | exit west stack; enter east leaf | LCA = DeepTop |
| Cross-stack → branch composite | LeafWestA → StackEast | arrow into composite | exit source stack; enter branch + initial chain | Target composite → initial leaf |
| Cross-stack → mid composite | LeafWestA → MidEast | arrow to mid composite | same as branch when initial chain matches | Often identical trace to branch target |
| Self | LeafWestA → LeafWestA | arrow to same state (rare) | none | Source equals destination leaf |
Trace convention (tutorial 05): push enter:StateName / exit:StateName from
onEntry / onExit; handler:event from the handler. Compare with
npm run test:examples -- --grep 'Tutorial 05'.
When and why: Hierarchy and transitions
Use hierarchy when substates share behaviour via inheritance (handlers on DeepTop) and when you need predictable entry/exit order across nested modes.
Why two files: trace-sibling.ts is a shallow A→B→C chain — easy to read exit/enter lines. machine.ts is the full topology table (sibling, parent, ancestor, cross-stack, async transition). Learn shallow first, then use the deep machine in tests and the trace panel.
When to call transition(): only when the active leaf class must change. Updating ctx.trace alone is an internal transition. The playground drives the deep machine — match its chart below.
State diagram
Full example source
Runnable code lives under 05-hierarchy. The listings below are the complete, commented sources used by the trace panel.
examples/05-hierarchy/trace-sibling.ts
/**
* Shallow hierarchy — A → B → C siblings under TraceTop.
*
* Use this file to learn entry/exit order before the deep machine in machine.ts.
* LCA for A→B and B→C is TraceTop; root onExit/onEntry do not repeat.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
export interface TraceCtx {
log: string[];
}
export interface TraceProtocol {
goToB(): void;
goToC(): void;
}
/** Shallow sibling chain — entry/exit order without deep nesting. */
export class TraceTop extends PlaygroundTopState<TraceCtx, TraceProtocol> {
onEntry(): void {
this.ctx.log.push('enter:Top');
}
onExit(): void {
this.ctx.log.push('exit:Top');
}
goToB(): void {
this.transition(B);
}
goToC(): void {
this.transition(C);
}
}
@ihsm.InitialState
export class A extends TraceTop {
onEntry(): void {
this.ctx.log.push('enter:A');
}
onExit(): void {
this.ctx.log.push('exit:A');
}
}
export class B extends TraceTop {
onEntry(): void {
this.ctx.log.push('enter:B');
}
onExit(): void {
this.ctx.log.push('exit:B');
}
}
export class C extends TraceTop {
onEntry(): void {
this.ctx.log.push('enter:C');
}
}
export function createTracer() {
return ihsm.makeHsm(TraceTop, { log: [] });
}
examples/05-hierarchy/machine.ts
/**
* Deep hierarchy — two stacks under DeepTop; every transition topology from tutorial 05.
*
* Handlers on DeepTop; ctx.trace records enter/exit/handler lines. Playground uses this file.
* Pair with trace-sibling.ts for a shallow A→B→C chain first.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
export interface DeepCtx {
trace: string[];
value: number;
/** When true, the next onExit that runs throws (for error demos). */
failExit: boolean;
}
export interface DeepProtocol {
tick(): void;
goSiblingWest(): void;
goParentWest(): void;
goAncestorWest(): void;
goRoot(): void;
goSelfWest(): void;
goCrossToLeafEastB(): void;
goCrossToBranchEast(): void;
goCrossToMidEast(): void;
goSiblingEast(): void;
goCrossToLeafWestB(): void;
goAsyncCrossEast(): void;
armFailExit(): void;
}
function pushTrace(ctx: DeepCtx, line: string): void {
ctx.trace.push(line);
}
/** Root — LCA for every cross-stack transition. */
export class DeepTop extends PlaygroundTopState<DeepCtx, DeepProtocol> {
onEntry(): void {
pushTrace(this.ctx, 'enter:DeepTop');
}
onExit(): void {
this.maybeFailExit('DeepTop');
pushTrace(this.ctx, 'exit:DeepTop');
}
tick(): void {
this.ctx.value += 1;
pushTrace(this.ctx, 'handler:tick');
}
goSiblingWest(): void {
this.transition(LeafWestB);
}
goParentWest(): void {
this.transition(MidWest);
}
goAncestorWest(): void {
this.transition(StackWest);
}
goRoot(): void {
this.transition(DeepTop);
}
goSelfWest(): void {
this.transition(LeafWestA);
}
goCrossToLeafEastB(): void {
this.transition(LeafEastB);
}
goCrossToBranchEast(): void {
this.transition(StackEast);
}
goCrossToMidEast(): void {
this.transition(MidEast);
}
goSiblingEast(): void {
this.transition(LeafEastA);
}
goCrossToLeafWestB(): void {
this.transition(LeafWestB);
}
async goAsyncCrossEast(): Promise<void> {
pushTrace(this.ctx, 'handler:goAsyncCrossEast:start');
await this.sleep(10);
pushTrace(this.ctx, 'handler:goAsyncCrossEast:after-await');
this.transition(LeafEastA);
}
armFailExit(): void {
this.ctx.failExit = true;
}
protected maybeFailExit(stateName: string): void {
if (this.ctx.failExit) {
this.ctx.failExit = false;
throw new Error(`forced exit failure in ${stateName}`);
}
}
}
/** West stack — initial branch after create. Depth: StackWest → MidWest → leaf. */
@ihsm.InitialState
export class StackWest extends DeepTop {
onEntry(): void {
pushTrace(this.ctx, 'enter:StackWest');
}
onExit(): void {
this.maybeFailExit('StackWest');
pushTrace(this.ctx, 'exit:StackWest');
}
}
@ihsm.InitialState
export class MidWest extends StackWest {
onEntry(): void {
pushTrace(this.ctx, 'enter:MidWest');
}
onExit(): void {
this.maybeFailExit('MidWest');
pushTrace(this.ctx, 'exit:MidWest');
}
}
@ihsm.InitialState
export class LeafWestA extends MidWest {
onEntry(): void {
pushTrace(this.ctx, 'enter:LeafWestA');
}
onExit(): void {
this.maybeFailExit('LeafWestA');
pushTrace(this.ctx, 'exit:LeafWestA');
}
}
export class LeafWestB extends MidWest {
onEntry(): void {
pushTrace(this.ctx, 'enter:LeafWestB');
}
onExit(): void {
this.maybeFailExit('LeafWestB');
pushTrace(this.ctx, 'exit:LeafWestB');
}
}
/** East stack — parallel deep branch under the same root. */
export class StackEast extends DeepTop {
onEntry(): void {
pushTrace(this.ctx, 'enter:StackEast');
}
onExit(): void {
this.maybeFailExit('StackEast');
pushTrace(this.ctx, 'exit:StackEast');
}
}
@ihsm.InitialState
export class MidEast extends StackEast {
onEntry(): void {
pushTrace(this.ctx, 'enter:MidEast');
}
onExit(): void {
this.maybeFailExit('MidEast');
pushTrace(this.ctx, 'exit:MidEast');
}
}
@ihsm.InitialState
export class LeafEastA extends MidEast {
onEntry(): void {
pushTrace(this.ctx, 'enter:LeafEastA');
}
onExit(): void {
this.maybeFailExit('LeafEastA');
pushTrace(this.ctx, 'exit:LeafEastA');
}
}
export class LeafEastB extends MidEast {
onEntry(): void {
pushTrace(this.ctx, 'enter:LeafEastB');
}
onExit(): void {
this.maybeFailExit('LeafEastB');
pushTrace(this.ctx, 'exit:LeafEastB');
}
}
export function createDeepMachine() {
return ihsm.makeHsm(DeepTop, { trace: [], value: 0, failExit: false });
}
/** After `create()` + `sync()`: outer → inner along `@ihsm.InitialState` chain. */
export const INIT_TRACE = ['enter:DeepTop', 'enter:StackWest', 'enter:MidWest', 'enter:LeafWestA'];
// Registered last so every export (including the const above) is initialized
// before the namespace is enumerated — avoids a TDZ error under strict bundlers.
ihsm.registerStateNames(self); // grabs every exported state automatically
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 05' for a headless check.
LCA algorithm (prototype chain)
States are classes; inheritance is the hierarchy. To transition from src to
dst:
- Walk
src→TopState, recording path and indexes. - Walk
dstupward until a class appears on thesrcpath — that is the LCA. - Exit states from the current leaf up to (not including) the LCA — only
classes that define their own
onExit(debug/verbose trace lists). - Enter states from the LCA down toward
dst; ifdstis composite, follow each@InitialStateuntil the deepest initial leaf. - Set
currentStateto that final leaf class.
Paths are cached per FromState=>ToState in _transitionCache.
Sync vs async with transitions
| Pattern | Behavior |
|---|---|
Sync handler + transition() | Handler completes → transition runs in same dispatch → sync() sees final state |
async handler + await + transition() | Transition runs after await; sync() waits for both |
this.post('e') inside handler | Deferred until current handler and its transition finish |
transition() in onEntry / onExit | Cleared at end of dispatch — use post() from lifecycle hooks instead |
sm.post('goAsyncCross');
await sm.sync(); // handler + transition + entry/exit complete
See sync() and tutorial 08.
Errors during transitions
| Failure | Error type | Default outcome |
|---|---|---|
| Handler throws | EventHandlerError | onError → often FatalErrorState |
| No handler | UnhandledEventError | onUnhandled → onError |
onExit / onEntry throws | TransitionError | Recovery → FatalErrorState |
onError throws | FatalError | FatalErrorState |
sync() drains the queue; with the default dispatchErrorCallback the
Promise still resolves after the machine enters FatalErrorState (the
callback throws to the console/logger, not to the caller). Override the callback
to propagate failures to application code.
Rules of thumb
- Called from event handlers (or recovery hooks).
- Deferred until handler completes successfully.
- Cleared if handler throws (unless recovered).
- Self-transition: no exit/entry when source equals target leaf and initial descent unchanged.
transition()insideonEntry/onExitof the same dispatch is cleared when that dispatch finishes — schedule follow-up work withpost()fromonEntry, or branch in the event handler (see tutorial 15).
When and why: Complex workflow
Use postNow from onEntry when a composite state must run immediate internal steps (validation, guards) before normal-priority post work from the same turn — classic “decision pseudo-state” without a separate class per micro-step.
Why not transition() inside onEntry: transitions scheduled from lifecycle hooks are cleared at end of dispatch. Branch with postNow (hi-priority) or move branching into the event handler.
When async handlers plus transitions: submit awaits work then transition(Validating); validating uses postNow('applyValidation') to approve or reject before deferred side effects.
State diagram
Full example source
Runnable code lives under 15-complex-workflow. The listings below are the complete, commented sources used by the trace panel.
examples/15-complex-workflow/machine.ts
/**
* Complex workflow — async submit, Validating + postNow guard, terminal states.
*
* Teaches: postNow from onEntry; transition() cleared if only scheduled from onExit/onEntry.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
export type OrderPhase = 'draft' | 'validating' | 'approved' | 'rejected' | 'completed';
export interface CheckoutCtx {
orderId: string;
amount: number;
limit: number;
phase: OrderPhase;
validationNotes: string[];
}
export interface CheckoutProtocol {
submit(): Promise<void>;
applyValidation(): void;
approve(): Promise<void>;
reject(reason: string): void;
getStatus(resolve: ihsm.ResolveCallback<OrderPhase>, reject: ihsm.RejectCallback): void;
}
export class CheckoutTop extends PlaygroundTopState<CheckoutCtx, CheckoutProtocol> {
getStatus(resolve: ihsm.ResolveCallback<OrderPhase>, _reject: ihsm.RejectCallback): void {
resolve(this.ctx.phase);
}
reject(_reason: string): void {
/* terminal Rejected state — optional manual reason already recorded */
}
}
@ihsm.InitialState
export class Draft extends CheckoutTop {
async submit(): Promise<void> {
this.ctx.phase = 'validating';
await this.sleep(10);
this.ctx.validationNotes.push('fraud-check-ok');
this.transition(Validating);
}
}
/** Decision pseudo state — guard runs via postNow after entry (hi-priority before normal post). */
export class Validating extends CheckoutTop {
onEntry(): void {
this.postNow('applyValidation');
}
applyValidation(): void {
if (this.ctx.amount <= this.ctx.limit) {
this.transition(Approved);
} else {
this.ctx.phase = 'rejected';
this.ctx.validationNotes.push('over-limit');
this.transition(Rejected);
}
}
}
export class Approved extends CheckoutTop {
async approve(): Promise<void> {
this.ctx.phase = 'approved';
this.transition(Completing);
}
}
export class Rejected extends CheckoutTop {}
export class Completing extends CheckoutTop {
async onEntry(): Promise<void> {
await this.sleep(10);
this.ctx.phase = 'completed';
}
}
ihsm.registerStateNames(self);
export function createCheckout(orderId: string, amount: number, limit: number) {
return ihsm.makeHsm(CheckoutTop, {
orderId,
amount,
limit,
phase: 'draft',
validationNotes: [],
});
}
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 15' for a headless check.
6. Tracing
Trace levels
| Level | Value | Use |
|---|---|---|
PRODUCTION | 0 | Minimal overhead |
DEBUG | 1 | Transition and handler boundaries |
VERBOSE_DEBUG | 2 | Lookup walks, cache hit/miss |
Set trace level: makeHsm(Top, ctx, true, TraceLevel.DEBUG).
Trace writer
Implement TraceWriter:
interface TraceWriter {
write(hsm, msg): void;
}
Default logs to console as domain|…|StateName: message. Inject a custom
writer for structured logging or tests (CollectingTraceWriter in
examples/shared/trace.ts).
Inside states: this.traceHeader, this.traceWriter, this.traceLevel.
Docs site: the reference page includes a live Trace panel in the browser. Tutorial
READMEs describe how to read VERBOSE_DEBUG output; run npm run test:examples for
headless verification.
(start here after tutorial 01). Every other tutorial includes a Reading the trace section.
XState: @xstate/inspect, Stately visualizer — external tooling vs
in-process trace hooks.
State display names (Node and minified browsers)
Trace output, error messages, currentStateName, and topStateName all read a
state's display name. By default that name comes from the JavaScript class
name (Class.name).
In Node (and any unminified build) class names are preserved, so everything works out of the box — no setup required.
In a minified browser bundle, bundlers (esbuild, terser, Rollup, webpack)
rename classes to short identifiers like t or e. Class.name then
returns the mangled name and your traces, currentStateName, and error messages
become unreadable. To keep names stable in every environment, register an
explicit display name for each state class.
There are two ways to keep names stable. Pick whichever fits your build.
Option 1 — keep class names in your bundler (zero code)
If you can afford slightly larger output, tell your minifier not to rename
classes. Then Class.name is preserved and no registration is needed:
| Bundler | Setting |
|---|---|
| esbuild | keepNames: true |
| terser | keep_classnames: true |
| webpack (TerserPlugin) | terserOptions: { keep_classnames: true } |
| Rollup (terser plugin) | terser({ keep_classnames: true }) |
Option 2 — register display names (no enumeration)
registerStateNames reads a stable name from each export key, which
minifiers preserve even when they mangle the class identifiers. The ergonomic
way is to register the module's own namespace — no need to list every state:
// machine.ts
import * as ihsm from 'ihsm';
import * as self from './machine'; // self-reference
export class DoorTop extends ihsm.TopState<DoorCtx, DoorProtocol> {}
export class Open extends DoorTop {}
export class Closed extends DoorTop {}
export function createDoor() {
return ihsm.makeHsm(DoorTop, { openCount: 0 });
}
ihsm.registerStateNames(self); // grabs every exported state automatically
Placement: put the registerStateNames(self) call after every export in the
module (it can stay above hoisted function declarations, but it must come after any
const/let/class export). Enumerating the self-namespace touches every export's
live binding; a const/class declared after the call is still in its temporal dead
zone and strict bundlers (e.g. Webpack SSR) will throw Cannot access … before initialization. When in doubt, make it the last statement of the file — or register
from a consumer module instead (below), which is never affected.
Equivalently, register from a consumer that imports the module as a namespace:
import * as machine from './machine';
registerStateNames(machine);
For one-off cases you can also name a single class explicitly:
import { defineStateName } from 'ihsm';
defineStateName(DoorTop, 'DoorTop');
In every form, factory functions and other non-state exports are ignored.
Notes:
- Names are stored as a non-enumerable, non-inherited own property, so a subclass never accidentally reports its parent's display name.
- Registration is idempotent for the same name; registering a different name for an already-named class throws (names are intended to be stable).
- The library registers its own built-ins (
TopState,FatalErrorState) automatically. - This is exactly how the bundled tutorials and the minified browser test suite
(
npm run test:browser, built withminify: true) keep their state names readable.
When and why: Tracing
Use tracing when you are debugging transition order, cache behaviour, or handler boundaries — especially after adopting hierarchy (tutorial 05).
Why not only console.log in handlers: the runtime already knows LCA paths, cache hits, and dispatch phases. TraceLevel.VERBOSE_DEBUG plus a TraceWriter (here CollectingTraceWriter) gives a consistent timeline without sprinkling logs in every onEntry/onExit.
When to inject a custom writer: tests (assert on trace lines), structured logging, or the docs site trace panel. Pass makeHsm(Top, ctx, true, TraceLevel.VERBOSE_DEBUG, writer) once; handlers use this.traceWriter indirectly via the framework.
State diagram
Full example source
Runnable code lives under 02-tracing. The listings below are the complete, commented sources used by the trace panel.
examples/02-tracing/machine.ts
/**
* Tracing example — ping handler with CollectingTraceWriter.
*
* Teaches: makeHsm(..., TraceLevel.VERBOSE_DEBUG, writer), trace lines from handlers.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
import { CollectingTraceWriter } from '../shared/trace';
/** Domain data updated by events. */
export interface PingCtx {
pings: number;
}
export interface PingProtocol {
ping(): void;
}
export class PingTop extends PlaygroundTopState<PingCtx, PingProtocol> {
ping(): void {
this.ctx.pings += 1;
// Custom writer receives domain|…|StateName: message (also in VERBOSE_DEBUG).
this.traceWriter.write(this, `ping count is now ${this.ctx.pings}`);
}
}
@ihsm.InitialState
export class Ready extends PingTop {}
ihsm.registerStateNames(self);
/** Verbose trace into a collector — used by the reference trace panel and tests. */
export function createTracedPing(writer: CollectingTraceWriter) {
return ihsm.makeHsm(PingTop, { pings: 0 }, true, ihsm.TraceLevel.VERBOSE_DEBUG, writer);
}
export function createPingMachine(writer: CollectingTraceWriter) {
return createTracedPing(writer);
}
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 02' for a headless check.
7. restore
hsm.restore(SavedStateClass, savedCtx);
Sets both active state class and context without running entry/exit.
Typical persistence flow:
// suspend — JSON row / file (state classes are not serializable)
const json = JSON.stringify({
stateName: 'Authenticated',
ctx: { ...hsm.ctx },
});
// resume — new instance after restart
const sm = makeHsm(TopState, emptyCtx, false);
sm.restore(STATE_BY_NAME[stateName], parsed.ctx);
Use for:
- Hydration from database snapshot
- Session reattachment after process restart
- Tests that need a mid-flow starting point
Does not replay history automatically — you choose the concrete state class and supply ctx.
XState: snapshot / restore on actors (v5 persisted state API).
When and why: restore
Use restore when you hydrate a machine from storage after restart — DB session, checkpoint, or test fixture — without replaying init entry/exit.
Why makeHsm(..., false) then restore: initialization runs onEntry descent; snapshots already represent “where we were”. restore(StateClass, ctx) sets leaf class and context atomically.
When to record state names: JSON cannot store class constructors — map string names to classes (SESSION_STATES) on resume. Keep ctx JSON-serializable.
State diagram
Full example source
Runnable code lives under 11-restore. The listings below are the complete, commented sources used by the trace panel.
examples/11-restore/machine.ts
/**
* restore — suspend/resume session without init entry/exit.
*
* Teaches: makeHsm(..., false), restore(StateClass, ctx), JSON persistence helpers.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
export interface SessionCtx {
userId: string;
lastPage: string;
/** Records onEntry calls — stays empty when restored from a snapshot. */
entryLog: string[];
}
export interface SessionProtocol {
navigate(page: string): void;
}
export class SessionTop extends PlaygroundTopState<SessionCtx, SessionProtocol> {
navigate(page: string): void {
this.ctx.lastPage = page;
}
}
@ihsm.InitialState
export class Anonymous extends SessionTop {
onEntry(): void {
this.ctx.entryLog.push('Anonymous');
}
}
export class Authenticated extends SessionTop {
onEntry(): void {
this.ctx.entryLog.push('Authenticated');
}
}
/** Map persisted state names back to state classes after JSON parse. */
export const SESSION_STATES = {
Anonymous,
Authenticated,
} as const;
export type SessionStateName = keyof typeof SESSION_STATES;
/** JSON-serializable row — what you store in a DB column or file. */
export interface PersistedSession {
stateName: SessionStateName;
ctx: SessionCtx;
}
/** In-memory stand-in for disk / DB (session id → JSON payload). */
export const sessionDb = new Map<string, string>();
ihsm.registerStateNames(self);
export function createSession(userId: string) {
return ihsm.makeHsm(SessionTop, { userId, lastPage: 'home', entryLog: [] });
}
function stateNameOf(sm: ihsm.Hsm<SessionCtx, SessionProtocol>): SessionStateName {
for (const [name, stateClass] of Object.entries(SESSION_STATES) as [SessionStateName, typeof Anonymous][]) {
if (sm.currentState === stateClass) {
return name;
}
}
throw new Error(`unknown active state: ${String(sm.currentState.name)}`);
}
/** Serialize active state + ctx to a JSON string (file or DB column). */
export function suspendSession(sm: ihsm.Hsm<SessionCtx, SessionProtocol>): string {
const payload: PersistedSession = {
stateName: stateNameOf(sm),
ctx: { ...sm.ctx, entryLog: [...sm.ctx.entryLog] },
};
return JSON.stringify(payload);
}
/** Parse JSON and hydrate a **new** machine instance — no init entry/exit. */
export function resumeSession(json: string) {
const { stateName, ctx } = JSON.parse(json) as PersistedSession;
const stateClass = SESSION_STATES[stateName];
const sm = ihsm.makeHsm(SessionTop, { userId: '', lastPage: '', entryLog: [] }, false);
sm.restore(stateClass, ctx);
return sm;
}
export function suspendSessionToDb(sessionId: string, sm: ihsm.Hsm<SessionCtx, SessionProtocol>): void {
sessionDb.set(sessionId, suspendSession(sm));
}
export function resumeSessionFromDb(sessionId: string) {
const json = sessionDb.get(sessionId);
if (!json) {
throw new Error(`session not found: ${sessionId}`);
}
return resumeSession(json);
}
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 11' for a headless check.
8. Error model
| Type | When |
|---|---|
UnhandledEventError | No handler for event in current state |
EventHandlerError | Handler threw |
InitializationError | onEntry during init failed |
FatalError | onError recovery failed |
InitialStateError | Two @InitialState on same parent |
Hooks:
onUnhandled(error)— default throws; override to recover or redirectonError(error)— default rethrows; override to log and transition
Fatal error state: FatalErrorState when transition recovery fails.
When and why: Error recovery
Use onError / onUnhandled when handlers can throw or when unknown events should recover instead of crashing the process — retries, counters, or transition to a safe state.
Why typed errors: EventHandlerError and UnhandledEventError carry event name and state context for logging. Override on the state class (or parent) that should own policy.
When to let errors propagate: fatal invariants — omit recovery and the machine enters FatalErrorState after failed recovery.
State diagram
Full example source
Runnable code lives under 12-error-recovery. The listings below are the complete, commented sources used by the trace panel.
examples/12-error-recovery/machine.ts
/**
* Error recovery — onError and onUnhandled on Working state.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
export interface WorkerCtx {
failures: number;
recovered: number;
}
export interface WorkerProtocol {
risky(): void;
unknown(): void;
}
export class WorkerTop extends PlaygroundTopState<WorkerCtx, WorkerProtocol> {
risky(): void {
throw new Error('simulated failure');
}
unknown(): void {
this.unhandled();
}
}
@ihsm.InitialState
export class Working extends WorkerTop {
onError<EventName extends keyof WorkerProtocol>(_error: ihsm.EventHandlerError<WorkerCtx, WorkerProtocol, EventName>): void {
this.ctx.recovered += 1;
this.ctx.failures += 1;
}
onUnhandled<EventName extends keyof WorkerProtocol>(_error: ihsm.UnhandledEventError<WorkerCtx, WorkerProtocol, EventName>): void {
this.ctx.failures += 1;
}
}
ihsm.registerStateNames(self);
export function createWorker() {
return ihsm.makeHsm(WorkerTop, { failures: 0, recovered: 0 });
}
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 12' for a headless check.
9. Async handlers
Major advantage: handlers may be async. The runtime awaits the returned
Promise before applying transition(). You can await an entire I/O pipeline
inside one handler while the machine stays in the same state class — no need
to invent Opening, Reading, Writing, or Closing states for mechanical
open/read/write/close work.
Classic tools (and XState invoke + done events) often require one state per
in-flight step because the handler must return immediately. ihsm keeps the actor
serialized: while one async handler runs, post / call messages queue until
it finishes.
Add extra states only when a waiting mode is domain-meaningful (cancel allowed, user-visible “Uploading”, different event set) — not for every syscall.
Example: open → read → write → close in one handler
@InitialState
class Idle extends FileTop {
async transfer(from: string, to: string): Promise<void> {
const readFd = await open(from, 'r');
const data = await read(readFd);
await close(readFd);
const writeFd = await open(to, 'w');
this.ctx.bytesWritten = await write(writeFd, data);
await close(writeFd);
this.transition(Done); // after entire pipeline — still was Idle until here
}
}
One event, one handler, one state during all awaits, one transition when done.
Mailbox during await
sm.post('transfer', '/inbox/a.dat', '/archive/a.dat');
await sm.sync(); // through open, read, write, close + transition
While awaiting, the mailbox still accepts post/call — messages queue
until the current handler finishes.
XState: often models async with invoke + done events — separate states
for in-flight work.
When and why: Async handlers
Use async handlers when one event performs a multi-step I/O pipeline and staying in one state until completion is correct — open/read/write/close without inventing substates per syscall.
Why ihsm awaits before transition(): the leaf class stays Idle through all awaits; queued post/call messages wait. Add substates only when “in flight” is a domain mode (cancellable upload, different events allowed).
When sync() matters: client waits until the whole transfer handler and its transition to Done finish.
State diagram
Full example source
Runnable code lives under 13-async-handlers. The listings below are the complete, commented sources used by the trace panel.
examples/13-async-handlers/machine.ts
/**
* Async handlers — full I/O pipeline in one handler while staying in Idle.
*
* transition(Done) runs only after all awaits complete.
*/
import * as ihsm from '../../src';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';
export interface FileCtx {
sourcePath: string;
destPath: string;
bytesWritten: number;
steps: string[];
}
export interface FileProtocol {
transfer(from: string, to: string): Promise<void>;
}
/** Simulated file API — each step returns a Promise like real I/O. */
async function open(path: string, mode: 'r' | 'w'): Promise<number> {
await Promise.resolve();
return mode === 'r' ? 1 : 2;
}
async function read(_fd: number): Promise<Buffer> {
await Promise.resolve();
return Buffer.from('payload-bytes', 'utf8');
}
async function write(_fd: number, data: Buffer): Promise<number> {
await Promise.resolve();
return data.length;
}
async function close(_fd: number): Promise<void> {
await Promise.resolve();
}
export class FileTop extends PlaygroundTopState<FileCtx, FileProtocol> {}
@ihsm.InitialState
export class Idle extends FileTop {
/**
* Entire open → read → write → close pipeline in **one handler**, **one state**.
* No Opening / Reading / Writing / Closing substates.
*/
async transfer(from: string, to: string): Promise<void> {
this.ctx.sourcePath = from;
this.ctx.destPath = to;
this.ctx.steps = [];
const readFd = await open(from, 'r');
this.ctx.steps.push('open(read)');
const data = await read(readFd);
this.ctx.steps.push('read');
await close(readFd);
this.ctx.steps.push('close(read)');
const writeFd = await open(to, 'w');
this.ctx.steps.push('open(write)');
this.ctx.bytesWritten = await write(writeFd, data);
this.ctx.steps.push('write');
await close(writeFd);
this.ctx.steps.push('close(write)');
this.transition(Done);
}
}
export class Done extends FileTop {}
ihsm.registerStateNames(self);
export function createFileActor() {
return ihsm.makeHsm(FileTop, {
sourcePath: '',
destPath: '',
bytesWritten: 0,
steps: [],
});
}
Try it
Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Tutorial 13' for a headless check.
10. makeHsm
Creates a machine instance bound to a context object and optionally runs initialization.
import { makeHsm, TraceLevel } from 'ihsm';
// Default: initialize=true, traceLevel=DEBUG, console trace writer
const door = makeHsm(DoorTop, { openCount: 0 });
await door.sync();
// Verbose trace into a custom writer (tests, structured logs)
const writer = new CollectingTraceWriter();
const traced = makeHsm(
DoorTop,
{ openCount: 0 },
true,
TraceLevel.VERBOSE_DEBUG,
writer,
);
// Skip init — hydrate from a snapshot (tutorial 11)
const sm = makeHsm(SessionTop, emptyCtx, false);
sm.restore(Authenticated, savedCtx);
makeHsm(
TopStateClass,
ctx,
initialize?, // default true — run onEntry descent
traceLevel?, // default TraceLevel.DEBUG
traceWriter?, // default console logger
dispatchErrorCallback? // default: log and rethrow
): Hsm<Context, Protocol>
| Parameter | Purpose |
|---|---|
topState | Root state class (required) |
ctx | Mutable domain context (required) |
traceLevel | PRODUCTION, DEBUG, or VERBOSE_DEBUG |
traceWriter | Custom TraceWriter (tests, structured logs) |
dispatchErrorCallback | Hook when dispatch throws and is not recovered |
Context and Protocol are inferred from the top state class — callers get a
fully typed Hsm<Context, Protocol> without manual generic arguments.
Pass initialize: false when you will immediately restore() a snapshot (tutorial 11).
Pass traceLevel, traceWriter, and dispatchErrorCallback on each call when
tests or deployments need non-default behavior.
11. Zero dependencies
package.json has no dependencies. Runtime uses only JavaScript builtins
(Map, Promise, setTimeout, Object.setPrototypeOf).
Implications:
- No transitive supply-chain risk from npm deps
- Suitable for embedded tooling, CLI, edge, strict enterprise policies
- Bundle size = your code + ihsm (~2.5k LOC source)
12. Code coverage
The runtime under src/ (excluding src/spec/) maintains 100% coverage:
npm test
| Metric | Target |
|---|---|
| Statements | 100% |
| Branches | 100% |
| Functions | 100% |
| Lines | 100% |
All three dispatch implementations (production, debug, verbose) are exercised.
Tutorial tests: npm run test:examples
13. Comparison with XState
| Concern | ihsm | XState v5 |
|---|---|---|
| State definition | classes | createMachine config |
| Hierarchy | extends | nested states: |
| Events | methods on Protocol (compile-time post / call) | { type: '...' } objects |
| Internal transition | omit transition() | internal: true transition |
| Guards | inline code | guard property |
| Parallel regions | multiple Hsm | type: 'parallel' |
| History | ctx / restore() | history pseudo-states |
| Async work | async handlers | invoke, actors |
| Request/response | call() → Promise | snapshot / spawned promises |
| Visualization | IDE + (future extract) | Stately editor |
| Dependencies | 0 | 0 (core) |
| Coverage | 100% runtime | project tests |
Choose ihsm when domain logic is class-oriented, typed services matter, and you want a tiny embeddable runtime. Choose XState when you need declarative visual specs, parallel regions in one chart, or frontend ecosystem integration.
14. API quick reference
makeHsm<Context, Protocol>
makeHsm(
topState,
ctx,
initialize?, // default true
traceLevel?, // default TraceLevel.DEBUG
traceWriter?, // default console logger
dispatchErrorCallback?, // default: log and rethrow
): Hsm<Context, Protocol>
| Parameter | Description |
|---|---|
topState | Root state class |
ctx | Domain context object |
traceLevel | PRODUCTION, DEBUG, or VERBOSE_DEBUG |
traceWriter | Custom trace sink |
dispatchErrorCallback | Hook when dispatch throws and is not recovered |
TopState<Context, Protocol>
| Method / property | Description |
|---|---|
ctx | Domain context |
transition(next) | Schedule state change |
post(event, ...args) | Enqueue event |
deferredPost(ms, event, ...args) | Timed enqueue |
call(service, ...args) | Enqueue service; returns Promise |
sleep(ms) | Promise delay helper |
unhandled() | Throw unhandled event |
onEntry / onExit | Lifecycle |
onError / onUnhandled | Recovery hooks |
Hsm<Context, Protocol>
| Method | Description |
|---|---|
sync() | Drain mailbox |
restore(state, ctx) | Rehydrate |
Errors
| Class | When |
|---|---|
UnhandledEventError | No handler in current state |
EventHandlerError | Handler threw |
InitializationError | Init onEntry failed |
FatalError | onError recovery failed |
InitialStateError | Duplicate @InitialState |
FatalErrorState | Terminal recovery-failure state |
Trace levels
| Name | Value |
|---|---|
TraceLevel.PRODUCTION | 0 |
TraceLevel.DEBUG | 1 |
TraceLevel.VERBOSE_DEBUG | 2 |
InitialState(StateClass)
Mark default substate of composite parent.
defineStateName(StateClass, name)
Assign a stable display name to one state class so traces, error messages, and
currentStateName survive minification. See
§6 State display names.
registerStateNames(exports)
Register display names in bulk from an exports map (export key → state class); non-state values are ignored. Recommended for minified browser bundles. See §6 State display names.
Learning path
- Read Key concepts and Tracing, then use the interactive examples on this page.
- Study Rules of thumb for integration patterns.