Skip to main content

Reference

ihsm is a zero-dependency hierarchical state machine library for TypeScript and JavaScript. States are classes, protocol members are methods, hierarchy is inheritance, and the runtime is an actor with serialized, run-to-completion dispatch.

Each machine declares one Config bag (context, services, notifications, internalServices, internalNotifications, optional port). Start at examples/00-config/.

Lineage: Harel’s hierarchical statecharts, encoded the Samek/QP way (class hierarchy + explicit transitions), with cached LCA transition paths and typed promise services on generated actor handles.

AttributeValue
Production dependencies0
Runtime test coverage≥94% lines (CI gate); target 100%
Node.js22+

Documentation: Reference · Testing


Introduction

ihsm targets TypeScript developers who model domain logic as classes rather than JSON statecharts. You get hierarchical states via inheritance, a typed Config vocabulary, and actor-style messaging with promise services (await actor.call.getBalance()) — 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

  1. Key concepts
  2. Key features
  3. Static type checking
  4. Messaging: notifications, services, sync
  5. Transitions
  6. Tracing
  7. restore()
  8. Error model
  9. Async handlers
  10. Factories
  11. Zero dependencies
  12. Code coverage
  13. Comparison with XState
  14. Quick reference
  15. Interactive examples (01–19)

Interactive examples (01–19)

Standard tutorials (0119, no 06 / 16). Use the table or the right-hand Table of contents under Playgrounds (01–19) — each entry scrolls to that tutorial’s interactive playground.

#TopicPlayground
01Hello state machine§01
02Tracing§02
03Context§03
04Protocol typing§04
05Hierarchy and transitions§05
07Internal transitions§07
08Notifications and sync§08
09Deferred post§09
10call services§10
11restore§11
12Error recovery§12
13Async handlers§13
14Nested machines (parent + child regions)§14
15Complex workflow§15
17notifyNow§17
18Chained child actors (not parallel states)§18
19Request manager (table + cancellable commands)§19

1. Key concepts

State as class

UML state diagram

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).

interface DoorConfig extends Config {
context: DoorCtx;
notifications: { open(): void; close(): void };
}

class DoorTop extends TopState<DoorConfig> {}

@InitialState
class Closed extends DoorTop {
open(): void {
this.hsm.transition(Open);
}
}

class Open extends DoorTop {
close(): void {
this.hsm.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().

Config

Each machine declares one Config interface: context, notifications, services, internalNotifications, internalServices, and optional port. Config bucket keys are discovered from handler methods on state classes (async → services, sync → notifications).

Client handles use actor.notify, actor.notifyNow, and await actor.call. Handler machinery (transition, port, sync) lives on this.hsm; deferred self-notifications use this.hsm.port.defer(ms).

See §3 Static type checking and examples/00-config/.

Actor run-to-completion dispatch

Each actor has an internal job queue. Notifications and services enqueue work; one job runs at a time, to completion. While a handler executes, new messages are queued, not re-entered.

XState: actor.send() with interpreter; similar serialization per actor.

Factories

makeActor creates the production external shell (public protocol only). makeChildActor composes a child under a parent. Tests use makeTestActor from ihsm/testing (full protocol + port introspection). Deterministic testing is covered in the Testing chapter — after the interactive examples there.

const door = makeActor(DoorTop, { openCount: 0 });
door.notify.open();
await door.hsm.sync();

Playground: 01 · Hello state machine


2. Key features

Summary table

FeatureHow in ihsmExplicit in library?
Contextctx on instanceYes
Typed vocabularyConfig + state handlersYes
Hierarchyclass extendsYes
Initial substate@InitialStateYes
Transitionthis.hsm.transition(StateClass)Yes
Cached LCA pathautomaticYes (internal)
Entry / exitonEntry() / onExit()Yes
Internal transitionhandle event, no transition()Implicit (by omission)
Guardsif in handlerImplicit (code)
Historyctx + restore()Implicit (data)
Orthogonal regionsmultiple actors (not type: 'parallel')Composition
Chained child actorsmakeChildActor in parent onEntryYes
Request table + cancellable commandsmanager parent + per-request child actorsComposition
Notificationsactor.notify.event() — fire-and-forgetYes
hsm.port.defer(ms)port timer (Port.setTimeout) + queueYes
Servicesawait actor.call.service() — PromiseYes
hsm.sync()drain queueYes
restoreset state + ctxYes
makeActorcreate + optional initYes
Tracinglevels + TraceWriterYes
Errorstyped error hierarchyYes
Async handlersasync methodsYes

Context

Mutable domain object passed as the second argument to makeActor. Survives transitions unless you replace it in restore().

Playground: 03 · Context

Config vocabulary

Declares the vocabulary of the machine in typed buckets (notifications, services, and optional internal buckets). Event and service 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 §3 Advanced: compile-time safety.

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.hsm.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.

Playground: 07 · Internal transitions

Guards

Use ordinary TypeScript:

approve(amount: number): void {
if (amount > this.ctx.limit) {
this.hsm.transition(Rejected);
return;
}
this.hsm.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 (why not parallel states)

ihsm does not implement UML type: 'parallel' regions inside a single chart. Parallel regions share one dispatch queue, one transition cache, one Config, and one Port — which is too weak for production domains that need:

  • Independent run-to-completion per concern (slow work in one region must not block another)
  • Separate public and internal protocols per region (internalServices, RequestingPort)
  • Owned lifecycle — spawn a concern in parent onEntry, tear it down in onExit
  • Typed await child.call.service() across boundaries without bolting actors on after the fact
  • Per-actor restore, tracing, and DST — each region gets its own TestPort and isolated tests
  • Phased or optional regions without combinatorial state products (N active modes × M link states)

Compose multiple full actors and coordinate with notify between them (avoid await child.call… across boundaries when event choreography is enough). Tutorial 14 is a parent order actor with payment/shipping child regions wired by event bridges. Tutorial 18 owns a single child for a narrower lifecycle. Tutorial 19 tracks many cancellable command children in a request table.

Playground: 14 · Nested machines (parent + child regions)

Chained child actors

When one machine owns another (session owns connection, checkout owns payment link), spawn the child in parent onEntry with makeChildActor(asParentActor(this), ChildTop, ctx, port), drive it with child.notify (and child.call when you need a typed Promise), and clear ctx.child in onExit. The child is a full Hsm with its own queue and protocol — not a passive parallel region.

Playground: 18 · Chained child actors (not parallel states)

Request manager (cancellable command children)

A manager actor keeps a request table, spawns AlphaTop / BetaTop command children per submit, and updates rows from commandFinished / commandCancelled internal events. Use child.notify.cancel() and deferred hsm.port.defer on commands so cancellation can race completion without cross-actor call.

Playground: 19 · Request manager (table + cancellable commands)


3. Static type checking

ihsm pushes correctness to compile time via Config and generated handles. At a glance:

interface WalletConfig {
context: Wallet;
notifications: { charge(amount: number): void };
services: { getBalance(): Promise<number> };
}

class PaymentTop extends TopState<WalletConfig> {}

const wallet = makeActor(PaymentTop, { balance: 0 });

wallet.notify.charge(10); // ✓ typed notification
// wallet.notify.charge('ten'); // ✗ string ≠ number

const balance = await wallet.call.getBalance(); // ✓ Promise<number>

Playground: 04 · Protocol typing


Advanced: compile-time safety (implementation notes)

The bullets below describe TypeScript mechanisms in the runtime.

What other libraries do not provide

Library / styleEvent namesPayload typescall return typeSame run-to-completion dispatch for events + services
ihsmkeyof bucket literalsinferred from method paramsPromise<T> from handler returnYes
XState v5string type on objectssetup().types mapssnapshot / spawned actors / waitForNo unified typed call
JavaScript FSMs (e.g. vanilla switch)runtime stringsnonecallbacks / manualN/A
Robot / SCXML portsstrings or enumsmanual validationad hocNo

Concrete gaps elsewhere:

  1. Stringly-typed eventssend({ type: 'setTargt' }) compiles unless you maintain a separate union and exhaustiveness checks; ihsm rejects notify.setTargt(…) because 'setTargt' is not a key on the notifications bucket.
  2. Untyped payloads — object events decouple payload shape from handler signature; ihsm derives the rest parameters of notify.setTarget(…) from the setTarget handler on Config.notifications.
  3. No typed request/response on the actor — XState and peers use getSnapshot(), child actors, or external promises; ihsm’s call.getBalance() returns Promise<number> inferred from the service handler return type.
  4. 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 classes declare handler methods on Config buckets.

ihsm is safe at compile time because Config buckets are the single source of truth for both state handler signatures and external notify / call / hsm.port.defer(ms) call sites.

Adopted typing strategy

Five rules define how a Config interface maps to the runtime dispatch:

RuleMeaning
1. One Config type parameterActorConfig (your Config bag) flows through makeActor, TopState, actor handles, and errors.
2. Events are void handlersA notification is a bucket method whose return type is void or Promise<void>. Payload types are everything before that return.
3. Services return Promise<Reply>A service (for call) is a method that returns T or Promise<T>. The client method returns Promise<T>.
4. Reserved names are excludedKeys reserved on State / handlers (e.g. transition, notify, ctx, hsm) cannot be protocol keys — they become never at the type level.
5. Disjoint protocol bucketsnotifications, internalNotifications, services, and internalServices must not share keys — enforced at compile time via Config.

State classes declare handler methods matching the Config buckets; TopState<YourConfig> binds typing for makeActor, notify, and call:

interface WalletConfig {
context: WalletCtx;
notifications: { deposit(amount: number): void };
services: { getBalance(): Promise<number> };
}

export class WalletTop extends TopState<WalletConfig> {
deposit(amount: number): void { /* … */ }
getBalance(): number { return this.ctx.balance; }
}

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

ActorConfig is inferred from TopState<YourConfig> and threaded through factories and handles:

export function makeActor<T extends TopStateArg<ActorConfig>>(
topState: T,
ctx: ActorContextOf<ActorConfigOf<T>>,
portOrOptions?: MachinePortInput<ActorConfigOf<T>> | ActorOptions<ActorConfigOf<T>>,
options?: ActorOptions<ActorConfigOf<T>>,
): ExternalActor<ActorConfigOf<T>>

export abstract class TopState<C extends ActorConfig = ActorConfig> { /* … */ }

Effect: makeActor(Top, ctx) returns ExternalActor<YourConfig> with a default production Port. Pass a custom port as the third argument, or pass ActorOptions alone when no custom port is needed.

2. Generic constraints (extends)
C extends ActorConfig
EventName extends string

Effect: ActorConfig is your structural Config bag. Event names are string literals discovered from bucket keys and handler methods.

3. keyof and literal event names
actor.notify.open(); // method name must exist on notifications bucket

Effect: notify.open(…) only accepts notification names from your Config buckets. Autocomplete in the IDE lists valid event names.

4. Indexed access types
Notifications[K] // or Services[K] for call facet

Used inside conditional types to read the method signature for a given event or service name.

5. Conditional types

Every helper type branches on bucket membership and whether a member is a valid notification or service:

export type NotificationArgs<N, K extends keyof N> =
N[K] extends (...args: infer A) => void | Promise<void> ? A : never;

export type ServiceReply<S> =
S extends (...args: never[]) => Promise<infer R> ? R
: S extends (...args: never[]) => infer R ? R
: never;

Effect:

  • Unknown or mismatched bucket keys → compile error at the facet call site.
  • Names on State machinery → excluded via FilterReservedKeys.
  • Non-void-return methods in the notifications bucket → payload becomes never (prefer void handlers for notifications).
6. infer — extract parameter tuples and return types

Event payloads — rest parameters after the event name:

Notifications[K] extends (...args: infer Payload) => Promise<void> | void
? Payload
: never

For setTarget(celsius: number): void, infer Payload is [celsius: number], so notify.setTarget(22) is valid and notify.setTarget('hot') is not.

Service request args — handler parameters before the return type:

export type ServiceArgs<S> =
S extends (...args: infer A) => Promise<unknown> ? A
: S extends (...args: infer A) => unknown ? A
: never;

Service response — handler return type (unwrapped from Promise):

export type ServiceReply<S> =
S extends (...args: never[]) => Promise<infer R> ? R
: S extends (...args: never[]) => infer R ? R
: never;

For getBalance(): Promise<number>, ServiceReply 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 notify to a protocol bucket key set, those keys collide with handler machinery and become never, producing a type error at call sites.

Payload never also blocks wrong arity:

// notifications: { setTarget(celsius: number): void }
wallet.notify.setTarget(); // ✗ missing argument
wallet.notify.setTarget(1, 2); // ✗ too many arguments
8. Faceted handles (notify / call)

Generated actor handles expose protocol members on facets — not as flat methods:

// ExternalActor<C> — production shell
actor.notify.charge(10);
await actor.call.getBalance();

// HandlerHsm — inside state methods
this.notify.tick();
this.notifyNow.lockInventory();

Effect: each call site gets a specialized check for the method name; TypeScript applies the matching handler signature from your Config buckets.

9. Rest parameters with inferred tuples

...payload on notify.event(…) is typed as an exact tuple derived from the handler, not as any[].

10. Structural Config (no extends Config)

Declare a plain interface with context, notifications, services, and optional internal buckets. TopState<YourConfig> binds typing for factories and facets.

11. Separate aliases for services vs events
export type FilterReservedKeys<T> =
{ [K in keyof T]: IsReservedName<K> extends true ? never : K }[keyof T];

call uses service names + request args; notify uses notification names + payloads. Buckets are disjoint — a key appears in services or notifications, not both.

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<
C extends ActorConfig = ActorConfig,
EventName extends string = string
> extends HsmError<C> {
eventName: EventName;
eventPayload: unknown[];
}

Effect: onError(error) inside a state can inspect error.eventName and error.eventPayload from the dispatch that failed.

Compile-time checks (summary table)

MistakeTypeScript error
Typo in event nameMethod missing on notify facet (compile error on unknown key)
Wrong payload typeArgument of type 'string' is not assignable to parameter of type 'number'
Wrong payload countTuple arity mismatch on rest parameters
Calling service with notifyService-shaped method may yield never payload or wrong inference — use call
Calling event with callRequest/response inference fails; return type may be never
Using reserved nameEvent name resolves to never
Drift between handler and ConfigMismatched handler signature vs bucket declaration; or wrong runtime dispatch

End-to-end flow

UML state diagram

  1. You define Config with context and protocol buckets.
  2. State classes declare handler methods matching those buckets.
  3. makeActor(TopState, ctx) infers Config from the top state class.
  4. External code calls notify.event(…) / call.service(…) — TypeScript validates against the same Config the handlers implement.
  5. 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: notifications, services, sync

Every messaging API has two sides:

SideWhereRole
HandlerMethod on the active state classRuns when the actor dispatches the event or service
ClientGenerated actor handlenotify, notifyNow, call, and await actor.hsm.sync()

Config types both: handler signatures on state classes and generated client methods.

Reading UML statecharts

this reference use PlantUML state diagrams. Map symbols to runtime behavior as follows:

Chart elementihsm 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 : labelExternal transition — handler calls this.hsm.transition(B); LCA exit/entry runs
StateName : event / action inside a state boxInternal transition — handler runs, no transition(), no exit/entry
Arrow crossing box boundaryExternal 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.
  • Spacingskinparam ranksep and skinparam nodesep add room between states.
  • Orthogonal linesskinparam 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 makeActor(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.

Notifications (actor.notify.event(…))

Fire-and-forget. The client enqueues on the default FIFO queue; the handler runs later on the active state.

Handler — method on Config.notifications (or internalNotifications for self/inbound):

@InitialState
class Closed extends DoorTop {
open(): void {
this.ctx.openCount += 1;
this.hsm.transition(Open);
}
}

Client — returns immediately; use await actor.hsm.sync() to wait:

door.notify.open();
await door.hsm.sync(); // handler + transition complete

Inside a state handler, this.notify.tick() schedules work after the current handler completes (and after any transition it requested). Use this.notifyNow.tick() for hi-priority delivery — see Tutorial 17.

Playground: 08 · Notifications and sync

Services (await actor.call.service(…))

Query the same actor through run-to-completion dispatch and receive a typed Promise.

Handler — return a value or Promise (Config.services):

getBalance(): number {
return this.ctx.balance;
}

async fetchBalance(id: string): Promise<number> {
const row = await db.load(id);
return row.balance;
}

Client:

const balance = await wallet.call.getBalance();
wallet.notify.deposit(50);
await wallet.hsm.sync();

Handlers cannot call this.call on themselves — that would deadlock. Cross-actor service calls use a different actor handle (child.call…, parent…).

Playground: 10 · call services

hsm.port.defer(ms) — deferred notifications

Schedule an event after a delay via the machine's port timer service, then enqueue normally. A machine without a custom port is always backed by a Port whose setTimeout-based timer is used here. Available inside handlers only (this.hsm.port.defer(ms)) — it is not exposed on the external actor surface.

Handler:

scheduleReminder(text: string): void {
this.hsm.port.defer(50).deliver(text); // returns immediately
}

deliver(text: string): void {
this.ctx.message = text;
}

Client:

sm.scheduleReminder('hello later');
await sleep(100); // wait for timer
await sm.hsm.sync(); // wait for deliver handler

Playground: 09 · Deferred post

actor.hsm.sync()

Returns a Promise that resolves when a sync marker task reaches the front of the queue — client-side only on actor.hsm (no handler to implement).

Client:

door.open();
await door.hsm.sync(); // through handler + its transition

sm.tick();
sm.tick();
sm.done();
await sm.hsm.sync(); // one sync drains all three notifications

After a handler chains this.notify.… calls, call hsm.sync() again (see the interactive example below).

Note: services return their own Promise; you usually do not need hsm.sync() after await actor.service().

notifyNow — hi-priority notifications

Handler-only hi-priority enqueue (this.notifyNow.event()). After the current handler and its transition finish, the runtime drains hi-priority jobs before normal notify notifications from the same turn.

Use for extended transitions — see tutorial 17.

Playground: 17 · notifyNow


5. Transitions

this.hsm.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).

KindExample (tutorial 05)Chart notationExit / entryNotes
Internaltick() in LeafWestALeafWestA : tick / value++ inside boxnonectx updates; state class unchanged
Child → sibling childLeafWestA → LeafWestBA --> B : goSiblingWestexit A, enter BLCA = parent (MidWest)
Child → parent compositeLeafWestA → MidWestarrow to parent compositeexit leaf; re-enter initial leafComposites with @InitialState descend again
Child → ancestorLeafWestB → StackWestarrow to ancestorexit up to LCA; enter down initial chainAncestors above LCA untouched
Child → rootLeafWestA → DeepToparrow to rootexit to LCA; re-enter initial branchRoot’s own onExit/onEntry skipped at LCA
Cross-stack leaf → leafLeafWestA → LeafEastBarrow across stacksexit west stack; enter east leafLCA = DeepTop
Cross-stack → branch compositeLeafWestA → StackEastarrow into compositeexit source stack; enter branch + initial chainTarget composite → initial leaf
Cross-stack → mid compositeLeafWestA → MidEastarrow to mid compositesame as branch when initial chain matchesOften identical trace to branch target
SelfLeafWestA → LeafWestAarrow to same state (rare)noneSource 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'.

Playground: 05 · Hierarchy and transitions

LCA algorithm (prototype chain)

States are classes; inheritance is the hierarchy. To transition from src to dst:

  1. Walk srcTopState, recording path and indexes.
  2. Walk dst upward until a class appears on the src path — that is the LCA.
  3. Exit states from the current leaf up to (not including) the LCA — only classes that define their own onExit (debug/verbose trace lists).
  4. Enter states from the LCA down toward dst; if dst is composite, follow each @InitialState until the deepest initial leaf.
  5. Set currentState to that final leaf class.

Paths are cached per FromState=>ToState in the RuntimeTransitionResolver cache.

Sync vs async with transitions

PatternBehavior
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.notify.e() inside handlerDeferred until current handler and its transition finish
transition() in onEntry / onExitCleared at end of dispatch — use this.notify from lifecycle hooks instead
sm.goAsyncCross();
await sm.hsm.sync(); // handler + transition + entry/exit complete

See actor.hsm.sync() and tutorial 08.

Errors during transitions

FailureError typeDefault outcome
Handler throwsEventHandlerErroronError → often FatalErrorState
No handlerUnhandledEventErroronUnhandledonError
onExit / onEntry throwsTransitionErrorRecovery → FatalErrorState
onError throwsFatalErrorFatalErrorState

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() inside onEntry/onExit of the same dispatch is cleared when that dispatch finishes — schedule follow-up work with this.notify from onEntry, or branch in the event handler (see tutorial 15).

Playground: 15 · Complex workflow


6. Tracing

Trace levels

LevelValueUse
PRODUCTION0Minimal overhead
DEBUG1Transition and handler boundaries
VERBOSE_DEBUG2Lookup walks, cache hit/miss

Set trace level when creating the actor:

const door = makeActor(DoorTop, { openCount: 0 }, {
traceLevel: 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:

BundlerSetting
esbuildkeepNames: true
terserkeep_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<DoorCtxConfig> {}
export class Open extends DoorTop {}
export class Closed extends DoorTop {}

export function createDoor() {
return ihsm.makeActor(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 with minify: true) keep their state names readable.

Playground: 02 · Tracing


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 = makeActor(TopState, emptyCtx, { initialize: false });
sm.hsm.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).

Playground: 11 · restore


8. Error model

TypeWhen
UnhandledEventErrorNo handler for event in current state
EventHandlerErrorHandler threw
InitializationErroronEntry during init failed
FatalErroronError recovery failed
InitialStateErrorTwo @InitialState on same parent

Hooks:

  • onUnhandled(error) — default throws; override to recover or redirect
  • onError(error) — default rethrows; override to log and transition

Fatal error state: FatalErrorState when transition recovery fails.

Playground: 12 · Error recovery


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, notify / 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.hsm.transition(Done); // after entire pipeline — still was Idle until here
}
}

One event, one handler, one state during all awaits, one transition when done.

Dispatch during await

sm.transfer('/inbox/a.dat', '/archive/a.dat');
await sm.hsm.sync(); // through open, read, write, close + transition

While awaiting, the actor still accepts notify / call — messages queue until the current handler runs to completion.

XState: often models async with invoke + done events — separate states for in-flight work.

Playground: 13 · Async handlers


10. Factories

FactoryReturnsUse when
makeActor(top, ctx, portOrOptions?, options?)ExternalActor<C>Production — public notify / notifyNow / call only
makeChildActor(parent, childTop, ctx, portOrOptions?, options?)ChildActor<C> + parentNested region owned by a parent handler
makeTestActor(top, ctx, portOrOptions?, options?) (ihsm/testing)TestActor<C>Tests — full protocol + port + subscribe

port.actor after makeActor is an inbound shell (InboundActor) — same public protocol plus internal notifications for port-driven events.

import { makeActor, Port, TraceLevel } from 'ihsm';

const door = makeActor(DoorTop, { openCount: 0 });
door.notify.open();
await door.hsm.sync();

const traced = makeActor(DoorTop, { openCount: 0 }, {
traceLevel: TraceLevel.VERBOSE_DEBUG,
traceWriter: new CollectingTraceWriter(),
});

const child = makeChildActor(asParentActor(this), ChildTop, childCtx);
await child.call.internalService();
child.hsm.restore(SavedState, savedCtx);

Config is inferred from TopState<YourConfig> and handler methods on state classes.


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 ≥94% line coverage (CI enforces via nyc check-coverage; target is 100% on statements/branches/functions/lines):

npm run test:node
npx nyc check-coverage
MetricCI floorTarget
Statements94%100%
Branches85%100%
Functions88%100%
Lines94%100%

All three dispatch implementations (production, debug, verbose) are exercised.

Tutorial tests: npm run test:examples


13. Comparison with XState

ConcernihsmXState v5
State definitionclassescreateMachine config
Hierarchyextendsnested states:
EventsConfig + typed methods{ type: '...' } objects
Internal transitionomit transition()internal: true transition
Guardsinline codeguard property
Parallel regionsmultiple Hsmtype: 'parallel'
Historyctx / restore()history pseudo-states
Async workasync handlersinvoke, actors
Request/responseactor.call → Promisesnapshot / spawned promises
VisualizationIDE + (future extract)Stately editor
Dependencies00 (core)
Coverage100% runtimeproject 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. Quick reference

Actor handles

SurfaceDeliveryReturnsExample
actor.notifydefault FIFO queuevoiddoor.notify.open()
actor.notifyNowpriority queuevoiddoor.notifyNow.lockInventory()
actor.callservice dispatchPromise<R>await wallet.call.getBalance()

Handlers use this.notify / this.notifyNow (no this.call — self-service would deadlock).

Deterministic simulation testing: work through the five interactive examples in Testing first; the DST checklist and tooling reference follow the examples there.

Factories

makeActor(topState, ctx, portOrOptions?, options?): ExternalActor<Config>
makeChildActor(parent, childTop, ctx, portOrOptions?, options?): ChildActor<Config> & { parent }
makeTestActor(topState, ctx, portOrOptions?, options?): TestActor<Config> // ihsm/testing

HandlerHsm (handlers: this.hsm)

MemberDescription
transition(next)Schedule state change
portOutbound boundary — defer(ms) for timed self-notifications; setTimeout / setInterval for delays
currentState / currentStateNameActive state
ctxDomain context (also on this.ctx)

For promise delays inside handlers: await new Promise(r => this.hsm.port.setTimeout(r, ms)).

Handler this.notify / this.notifyNow enqueue self-notifications (normal / hi-priority).

ChildHsm (clients: child.hsm)

MemberDescription
sync()Drain pending work
restore(state, ctx)Rehydrate without entry/exit
currentState / currentStateNameActive state

Ports & testing

TypeRole
Port<typeof TopState>Production port: timers + random
TestPort<typeof TopState>Virtual clock, mocked random, send / record
@mock + makeTestPortTyped domain port doubles for tests

Errors

ClassWhen
UnhandledEventErrorNo handler in current state
EventHandlerErrorHandler threw
InitializationErrorInit onEntry failed
FatalErroronError recovery failed
InitialStateErrorDuplicate @InitialState
FatalErrorStateTerminal recovery-failure state

Trace levels

NameValue
TraceLevel.PRODUCTION0
TraceLevel.DEBUG1
TraceLevel.VERBOSE_DEBUG2

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.


Playgrounds (01–19)

01 · Hello state machine

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 makeActor: you need actor semantics (serialized, run-to-completion dispatch), typed notify / call, and optional tracing — not a one-off callback. For a single open/close loop, this is the smallest correct shape: DoorConfig, @InitialState, and transition() between siblings under one root.

State diagram

UML 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, Door, TopState root, @InitialState, hsm.transition()
* between sibling states, registerStateNames, makeActor factory.
*/
import * as ihsm from '../../src';
import { makeTestActor } from '../../src/testing';
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;
}

export interface DoorConfig {
context: DoorCtx;
notifications: {
open(): void;
close(): void;
};
}

/** Root state: inherits run-to-completion dispatch, transition(), and tracing from TopState. */
export class DoorTop extends PlaygroundTopState<DoorConfig> {}

/** Initial leaf after makeActor + 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.hsm.transition(Open);
}
}

export class Open extends DoorTop {
close(): void {
this.hsm.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 makeTestActor(DoorTop, { openCount: 0 });
}

01 · Hello state machine

Interactive playgroundDoor machine
StateState: DoorTop · openCount: 0

02 · Tracing

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 makeActor(Top, ctx, { traceLevel: TraceLevel.VERBOSE_DEBUG, traceWriter: writer }) once; handlers use this.hsm.traceWriter indirectly via the framework.

State diagram

UML 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: makeActor(..., { traceLevel, traceWriter }), trace lines from handlers.
*/
import * as ihsm from '../../src';
import { makeTestActor } from '../../src/testing';
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 PingConfig {
context: PingCtx;
notifications: {
ping(): void;
};
}

export class PingTop extends PlaygroundTopState<PingConfig> {
ping(): void {
this.ctx.pings += 1;
// Custom writer receives domain|…|StateName: message (also in VERBOSE_DEBUG).
this.hsm.traceWriter.write(this.hsm as never, `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 makeTestActor(
PingTop,
{ pings: 0 },
{
traceLevel: ihsm.TraceLevel.VERBOSE_DEBUG,
traceWriter: writer,
}
);
}

export function createPingMachine(writer: CollectingTraceWriter) {
return createTracedPing(writer);
}

02 · Tracing

Interactive playgroundTraced ping machine
StateState: PingTop · pings: 0

03 · Context

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 makeActor 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

UML 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 { makeTestActor } from '../../src/testing';
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 CounterConfig {
context: CounterCtx;
notifications: {
increment(): void;
decrement(): void;
reset(): void;
};
}

export class CounterTop extends PlaygroundTopState<CounterConfig> {
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 makeTestActor(CounterTop, { value: initial, step });
}

03 · Context

Interactive playgroundCounter machine
StateState: CounterTop · value: 0 · step: 1

04 · Protocol typing

When and why: Protocol typing

Use a Config interface whenever callers notify 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 TopState<YourConfig> 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

UML 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 actor notification and service methods.
*
* Uncomment the lines at the bottom locally to see TypeScript reject typos.
*/
import * as ihsm from '../../src';
import { makeTestActor } from '../../src/testing';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';

export interface ThermostatCtx {
celsius: number;
}

export interface ThermostatConfig {
context: ThermostatCtx;
notifications: {
setTarget(celsius: number): void;
};
services: {
readTarget(): Promise<number>;
};
}

export class ThermostatTop extends PlaygroundTopState<ThermostatConfig> {
setTarget(celsius: number): void {
this.ctx.celsius = celsius;
}

readTarget(): number {
return this.ctx.celsius;
}
}

@ihsm.InitialState
export class Idle extends ThermostatTop {}

ihsm.registerStateNames(self);

export function createThermostat(initialCelsius: number) {
return makeTestActor(ThermostatTop, { celsius: initialCelsius });
}

// Compile-time examples (uncomment to verify the compiler rejects mistakes):
// const t = createThermostat(20);
// t.setTargt(22); // error: unknown method
// t.setTarget('hot'); // error: string not assignable to number

04 · Protocol typing

Interactive playgroundThermostat machine
StateState: ThermostatTop · target: 18°C

05 · Hierarchy and transitions

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

UML 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 { makeTestActor } from '../../src/testing';
import { PlaygroundTopState } from '../shared/playground-top';

export interface TraceCtx {
log: string[];
}

export interface TraceConfig {
context: TraceCtx;
notifications: {
goToB(): void;
goToC(): void;
};
}

/** Shallow sibling chain — entry/exit order without deep nesting. */
export class TraceTop extends PlaygroundTopState<TraceConfig> {
onEntry(): void {
this.ctx.log.push('enter:Top');
}
onExit(): void {
this.ctx.log.push('exit:Top');
}
goToB(): void {
this.hsm.transition(B);
}
goToC(): void {
this.hsm.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 makeTestActor(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 { makeTestActor } from '../../src/testing';
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 DeepConfig {
context: DeepCtx;
notifications: {
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<DeepConfig> {
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.hsm.transition(LeafWestB);
}
goParentWest(): void {
this.hsm.transition(MidWest);
}
goAncestorWest(): void {
this.hsm.transition(StackWest);
}
goRoot(): void {
this.hsm.transition(DeepTop);
}
goSelfWest(): void {
this.hsm.transition(LeafWestA);
}
goCrossToLeafEastB(): void {
this.hsm.transition(LeafEastB);
}
goCrossToBranchEast(): void {
this.hsm.transition(StackEast);
}
goCrossToMidEast(): void {
this.hsm.transition(MidEast);
}
goSiblingEast(): void {
this.hsm.transition(LeafEastA);
}
goCrossToLeafWestB(): void {
this.hsm.transition(LeafWestB);
}
async goAsyncCrossEast(): Promise<void> {
pushTrace(this.ctx, 'handler:goAsyncCrossEast:start');
await new Promise<void>(resolve => (this.hsm.port as unknown as ihsm.Port).setTimeout(resolve, 10));
pushTrace(this.ctx, 'handler:goAsyncCrossEast:after-await');
this.hsm.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 makeTestActor(DeepTop, { trace: [], value: 0, failExit: false }, new ihsm.Port());
}

/** 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

05 · Hierarchy and transitions

Interactive playgroundDeep hierarchy machine
StateState: DeepTop · value: 0 · ctx.trace lines: 0 · failExit: false

07 · Internal transitions

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

UML 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 { makeTestActor } from '../../src/testing';
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 LampConfig {
context: LampCtx;
notifications: {
dim(delta: number): void;
brighten(delta: number): void;
};
}

export class LampTop extends PlaygroundTopState<LampConfig> {
onEntry(): void {
this.ctx.entryCount += 1;
}

dim(delta: number): void {
this.ctx.brightness = Math.max(0, this.ctx.brightness - delta);
// Internal transition: no hsm.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 makeTestActor(LampTop, { brightness, entryCount: 0 });
}

07 · Internal transitions

Interactive playgroundLamp machine
StateState: LampTop · brightness: 50 · entryCount: 0

08 · Notifications and sync

When and why: Notifications and sync

Use notify + 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 chained this.notify inside a handler defer: this.notify.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 notifications from one handler, one marker drains the whole queue through done. After call(), you usually await the returned Promise instead.

State diagram

UML 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 hsm.actor notifications 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 { makeTestActor } from '../../src/testing';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';

export interface QueueCtx {
/** Append-only log of handler names — order proves run-to-completion serialization. */
events: string[];
}

export interface QueueConfig {
context: QueueCtx;
notifications: {
start(): void;
tick(): void;
done(): void;
};
}

export class QueueTop extends PlaygroundTopState<QueueConfig> {
start(): void {
this.ctx.events.push('start');
// These run after start() returns — not inline during start.
this.notify.tick();
this.notify.tick();
this.notify.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 makeTestActor(QueueTop, { events: [] });
}

08 · Notifications and sync

Interactive playgroundQueue machine
StateState: QueueTop · events: []

09 · Deferred post

When and why: Deferred post

Use hsm.port.defer(ms) when a handler must schedule a follow-up notification after a delay without blocking the current handler — reminders, retries, or UI debouncing.

Why not setTimeout + manual notify in app code: port.defer still goes through the actor's run-to-completion dispatch (serialized with other events) and respects the same state instance. The delay is implemented by the machine's port timer service — a Port the runtime always instantiates when you don't supply one — so you stay in the protocol vocabulary. It is handler-only and never reaches the external actor surface.

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

UML 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

/**
* defer — schedule deliver after 50ms without blocking scheduleReminder.
*/
import * as ihsm from '../../src';
import { makeTestActor } from '../../src/testing';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';

export interface ReminderCtx {
message: string;
}

export interface ReminderConfig {
context: ReminderCtx;
notifications: {
scheduleReminder(text: string): void;
deliver(text: string): void;
};
}

export class ReminderTop extends PlaygroundTopState<ReminderConfig> {
scheduleReminder(text: string): void {
// Returns immediately; deliver is enqueued when the timer fires.
this.hsm.port.defer(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 makeTestActor(ReminderTop, { message: '' }, new ihsm.Port());
}

09 · Deferred post

Interactive playgroundReminder machine
StateState: ReminderTop · message: ""

10 · call services

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 run-to-completion 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 notify instead: fire-and-forget side effects where nobody awaits an outcome. Mix both on one machine: notifications mutate state; services answer questions.

State diagram

UML 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 returning Promise directly.
*
* Client: await wallet.getBalance() — no resolve/reject in handler signatures.
*/
import * as ihsm from '../../src';
import { makeTestActor } from '../../src/testing';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';

export interface WalletCtx {
balance: number;
}

export interface WalletConfig {
context: WalletCtx;
notifications: {
deposit(amount: number): void;
};
services: {
getBalance(): Promise<number>;
fetchBalanceDelayed(delayMs: number): Promise<number>;
withdraw(amount: number): Promise<number>;
};
}

export class WalletTop extends PlaygroundTopState<WalletConfig> {
deposit(amount: number): void {
this.ctx.balance += amount;
}

getBalance(): number {
return this.ctx.balance;
}

async fetchBalanceDelayed(delayMs: number): Promise<number> {
await new Promise<void>(resolve => (this.hsm.port as unknown as ihsm.Port).setTimeout(resolve, delayMs));
return this.ctx.balance;
}

withdraw(amount: number): number {
if (amount > this.ctx.balance) {
throw new Error('insufficient funds');
}
this.ctx.balance -= amount;
return this.ctx.balance;
}
}

@ihsm.InitialState
export class Open extends WalletTop {}

ihsm.registerStateNames(self);

export function createWallet(initialBalance: number) {
return makeTestActor(WalletTop, { balance: initialBalance }, new ihsm.Port());
}

10 · call services

Interactive playgroundWallet machine
StateState: WalletTop · balance: 100

11 · restore

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 makeActor(..., { initialize: 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

UML 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: makeActor(..., { initialize: false }), hsm.restore(StateClass, ctx), JSON persistence helpers.
*/
import * as ihsm from '../../src';
import { makeTestActor, type TestActor } from '../../src/testing';
import type { ChildHsm } 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 SessionConfig {
context: SessionCtx;
notifications: {
navigate(page: string): void;
};
}

export class SessionTop extends PlaygroundTopState<SessionConfig> {
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 makeTestActor(SessionTop, { userId, lastPage: 'home', entryLog: [] });
}

function stateNameOf(sm: TestActor<SessionConfig>): SessionStateName {
const name = sm.hsm.currentStateName as SessionStateName;
if (!(name in SESSION_STATES)) {
throw new Error(`unknown active state: ${name}`);
}
return name;
}

/** Serialize active state + ctx to a JSON string (file or DB column). */
export function suspendSession(sm: TestActor<SessionConfig>): 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 = makeTestActor(
SessionTop as ihsm.TopStateArg<SessionConfig>,
{ userId: '', lastPage: '', entryLog: [] },
{
initialize: false,
}
);
(sm.hsm as ChildHsm<SessionConfig>).restore(stateClass, ctx);
return sm;
}

export function suspendSessionToDb(sessionId: string, sm: TestActor<SessionConfig>): 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);
}

11 · restore

Interactive playgroundSession machine
StateState: SessionTop · user: guest · page: home · entryLog: []

12 · Error recovery

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

UML 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 { makeTestActor } from '../../src/testing';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';

export interface WorkerCtx {
failures: number;
recovered: number;
}

export interface WorkerConfig {
context: WorkerCtx;
notifications: {
risky(): void;
unknown(): void;
};
}

export class WorkerTop extends PlaygroundTopState<WorkerConfig> {
risky(): void {
throw new Error('simulated failure');
}

unknown(): void {
this.hsm.unhandled();
}
}

@ihsm.InitialState
export class Working extends WorkerTop {
onError(_error: ihsm.EventHandlerError<WorkerConfig>): void {
this.ctx.recovered += 1;
this.ctx.failures += 1;
}

onUnhandled(_error: ihsm.UnhandledEventError<WorkerConfig>): void {
this.ctx.failures += 1;
}
}

ihsm.registerStateNames(self);

export function createWorker() {
return makeTestActor(WorkerTop, { failures: 0, recovered: 0 });
}

12 · Error recovery

Interactive playgroundWorker machine
StateState: WorkerTop · failures: 0 · recovered: 0

13 · Async handlers

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 notify/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

UML 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 { makeTestActor } from '../../src/testing';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';

export interface FileCtx {
sourcePath: string;
destPath: string;
bytesWritten: number;
steps: string[];
}

export interface FileConfig {
context: FileCtx;
notifications: {
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<FileConfig> {}

@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.hsm.transition(Done);
}
}

export class Done extends FileTop {}

ihsm.registerStateNames(self);

export function createFileActor() {
return makeTestActor(
FileTop,
{
sourcePath: '',
destPath: '',
bytesWritten: 0,
steps: [],
},
new ihsm.Port()
);
}

13 · Async handlers

Interactive playgroundFile transfer machine
StateState: FileTop · bytesWritten: 0 · steps: []

14 · Nested machines (parent + child regions)

When and why: Nested machines (parent + child regions)

OrderTop is a parent actor; payment and shipping are child actors from makeChildActor. This models UML orthogonal regions without type: 'parallel' in one chart.

Event-only between actors: parent drives child.notify.markPaid(); children report orderEvents.paymentDone() back through wired notify bridges — no await child.call… across boundaries. Sequence fulfill with parent internalNotifications (beginPaymentpaymentDonebeginShipping).

Multi-queue sync: each actor has its own FIFO — drain parent and children (syncOrderRegions) after driving events in tests.

State diagram

UML 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

/**
* Parallel regions — Order parent actor owns payment and shipping child actors.
* Parent/child coordination uses notifications only (no cross-actor call/await).
*/
import * as ihsm from '../../src';
import type { ChildActor } from '../../src';
import { makeTestActor } from '../../src/testing';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';

/** Events children fire back to the order parent (wired at spawn). */
export interface OrderRegionEvents {
paymentDone(): void;
shippingDone(): void;
}

/** Payment child — own queue and transition cache. */
export interface PaymentCtx {
paid: boolean;
orderEvents?: OrderRegionEvents;
}

export interface PaymentConfig {
context: PaymentCtx;
internalNotifications: {
markPaid(): void;
};
}

export class PaymentTop extends PlaygroundTopState<PaymentConfig> {
markPaid(): void {
this.ctx.paid = true;
this.hsm.transition(PaymentDone);
this.ctx.orderEvents?.paymentDone();
}
}

@ihsm.InitialState
export class PaymentPending extends PaymentTop {}

export class PaymentDone extends PaymentTop {}

/** Shipping child — independent lifecycle from payment. */
export interface ShippingCtx {
shipped: boolean;
orderEvents?: OrderRegionEvents;
}

export interface ShippingConfig {
context: ShippingCtx;
internalNotifications: {
markShipped(): void;
};
}

export class ShippingTop extends PlaygroundTopState<ShippingConfig> {
markShipped(): void {
this.ctx.shipped = true;
this.hsm.transition(ShippingDone);
this.ctx.orderEvents?.shippingDone();
}
}

@ihsm.InitialState
export class ShippingWaiting extends ShippingTop {}

export class ShippingDone extends ShippingTop {}

/** Order parent — spawns region children and sequences fulfill via events. */
export interface OrderCtx {
payment?: ChildActor<PaymentConfig>;
paymentCtx?: PaymentCtx;
shipping?: ChildActor<ShippingConfig>;
shippingCtx?: ShippingCtx;
}

export interface OrderConfig {
context: OrderCtx;
notifications: {
fulfill(): void;
};
internalNotifications: {
beginPayment(): void;
paymentDone(): void;
beginShipping(): void;
shippingDone(): void;
};
}

export class OrderTop extends PlaygroundTopState<OrderConfig> {
fulfill(): void {
this.hsm.transition(Fulfilling);
}

beginPayment(): void {
this.ctx.payment!.notify.markPaid();
}

paymentDone(): void {
this.notifyNow.beginShipping();
}

beginShipping(): void {
this.ctx.shipping!.notify.markShipped();
}

shippingDone(): void {
this.hsm.transition(Fulfilled);
}

protected spawnRegions(): void {
if (this.ctx.payment) {
return;
}
const orderEvents: OrderRegionEvents = {
paymentDone: () => this.notifyNow.paymentDone(),
shippingDone: () => this.notifyNow.shippingDone(),
};
const paymentCtx: PaymentCtx = { paid: false, orderEvents };
const shippingCtx: ShippingCtx = { shipped: false, orderEvents };
this.ctx.paymentCtx = paymentCtx;
this.ctx.shippingCtx = shippingCtx;
this.ctx.payment = ihsm.makeChildActor(ihsm.asParentActor(this), PaymentTop, paymentCtx);
this.ctx.shipping = ihsm.makeChildActor(ihsm.asParentActor(this), ShippingTop, shippingCtx);
}
}

@ihsm.InitialState
export class Open extends OrderTop {
onEntry(): void {
this.spawnRegions();
}
}

export class Fulfilling extends OrderTop {
onEntry(): void {
this.notifyNow.beginPayment();
}
}

export class Fulfilled extends OrderTop {}

ihsm.registerStateNames(self);

export function createOrder() {
return makeTestActor(OrderTop, {});
}

/** Drain parent and both region queues (tests / playground). */
export async function syncOrderRegions(order: ReturnType<typeof createOrder>): Promise<void> {
await order.hsm.sync();
if (order.ctx.payment) {
await order.ctx.payment.hsm.sync();
}
if (order.ctx.shipping) {
await order.ctx.shipping.hsm.sync();
}
await order.hsm.sync();
}

14 · Nested machines (parent + child regions)

Interactive playgroundOrder parent actor
StateOrder: OrderTop

15 · Complex workflow

When and why: Complex workflow

Use notifyNow from onEntry when a composite state must run immediate internal steps (validation, guards) before normal-priority notify 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 notifyNow (hi-priority) or move branching into the event handler.

When async handlers plus transitions: submit awaits work then transition(Validating); validating uses this.notifyNow.applyValidation() to approve or reject before deferred side effects.

State diagram

UML 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 + immediate guard, terminal states.
*
* Teaches: hsm.immediate from onEntry; transition() cleared if only scheduled from onExit/onEntry.
*/
import * as ihsm from '../../src';
import { makeTestActor } from '../../src/testing';
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 CheckoutConfig {
context: CheckoutCtx;
notifications: {
submit(): Promise<void>;
applyValidation(): void;
approve(): Promise<void>;
reject(reason: string): void;
};
services: {
getStatus(): Promise<OrderPhase>;
};
}

export class CheckoutTop extends PlaygroundTopState<CheckoutConfig> {
getStatus(): OrderPhase {
return 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 new Promise<void>(resolve => (this.hsm.port as unknown as ihsm.Port).setTimeout(resolve, 10));
this.ctx.validationNotes.push('fraud-check-ok');
this.hsm.transition(Validating);
}
}

/** Decision pseudo state — guard runs via immediate after entry (hi-priority before normal post). */
export class Validating extends CheckoutTop {
onEntry(): void {
this.notifyNow.applyValidation();
}

applyValidation(): void {
if (this.ctx.amount <= this.ctx.limit) {
this.hsm.transition(Approved);
} else {
this.ctx.phase = 'rejected';
this.ctx.validationNotes.push('over-limit');
this.hsm.transition(Rejected);
}
}
}

export class Approved extends CheckoutTop {
async approve(): Promise<void> {
this.ctx.phase = 'approved';
this.hsm.transition(Completing);
}
}

export class Rejected extends CheckoutTop {}

export class Completing extends CheckoutTop {
async onEntry(): Promise<void> {
await new Promise<void>(resolve => (this.hsm.port as unknown as ihsm.Port).setTimeout(resolve, 10));
this.ctx.phase = 'completed';
}
}

ihsm.registerStateNames(self);

export function createCheckout(orderId: string, amount: number, limit: number) {
return makeTestActor(
CheckoutTop,
{
orderId,
amount,
limit,
phase: 'draft',
validationNotes: [],
},
new ihsm.Port()
);
}

15 · Complex workflow

Interactive playgroundCheckout workflow
StateState: CheckoutTop · phase: draft · amount: 120 · notes: []

17 · notifyNow

When and why: notifyNow

Use notifyNow for extended transitions: several internal steps (lock inventory, capture payment) that must complete in order before normal notify messages from the same handler — e.g. cancel notified in the same confirm() must not run until hi-priority steps finish.

Why handler-only: external clients use ordinary notify; 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 notify from the same turn.

State diagram

UML 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

/**
* immediate — hi-priority steps before normal actor notifications from the same confirm() handler.
*
* confirm schedules cancel (normal) but lock/capture run via immediate first.
*/
import * as ihsm from '../../src';
import { makeTestActor } from '../../src/testing';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';

export interface CheckoutCtx {
steps: string[];
committed: boolean;
cancelled: boolean;
}

export interface CheckoutConfig {
context: CheckoutCtx;
notifications: {
confirm(): void;
lockInventory(): void;
capturePayment(): void;
cancel(): void;
};
}

export class CheckoutTop extends PlaygroundTopState<CheckoutConfig> {
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.notify.cancel();
this.notifyNow.lockInventory();
this.notifyNow.capturePayment();
this.ctx.steps.push('confirm-end');
this.hsm.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 makeTestActor(
CheckoutTop,
{
steps: [],
committed: false,
cancelled: false,
},
new ihsm.Port()
);
}

17 · notifyNow

Interactive playgroundCheckout postNow
StateState: CheckoutTop · steps: [] · committed: false · cancelled: false

18 · Chained child actors (not parallel states)

When and why: Chained child actors (not parallel states)

ihsm rejects UML parallel regions inside one chart — they share one queue, one Config, and one port. Real systems need independent dispatch, per-concern protocols, optional lifecycles, and typed await child.call… across boundaries.

Use makeChildActor(asParentActor(this), ChildTop, ctx, port) when a parent state owns a child: spawn in onEntry, drop the handle in onExit, orchestrate with child.notify / child.call. Stronger than parallel states: phased concerns, internal child vocabulary, isolated DST mocks, parent-orchestrated retries.

Versus tutorial 14: sibling regions under one parent actor with event bridges; tutorial 18 when a single child is owned by a composite parent state with a narrower lifecycle.

State diagram

UML state diagram

Full example source

Runnable code lives under 18-chained-child-actors. The listings below are the complete, commented sources used by the trace panel.

examples/18-chained-child-actors/machine.ts

/**
* Chained child actors — parent session owns a Link child via makeChildActor.
* Contrasts with UML parallel regions; see tutorial 14 for a multi-child parent actor.
*/
import * as ihsm from '../../src';
import type { ChildActor } from '../../src';
import { makeTestActor } from '../../src/testing';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';

/** Child link — own queue, port, and internal open/dial vocabulary. */
export interface LinkCtx {
host: string;
attempts: number;
linkUp: boolean;
lastPayload: string;
}

export interface LinkConfig {
context: LinkCtx;
services: {
deliver(payload: string): Promise<boolean>;
};
internalNotifications: {
open(host: string): void;
finishDial(): void;
};
internalServices: {
dial(): Promise<boolean>;
};
}

export class LinkTop extends PlaygroundTopState<LinkConfig> {
open(host: string): void {
this.ctx.host = host;
this.hsm.transition(Connecting);
}

async dial(): Promise<boolean> {
this.ctx.attempts += 1;
const ok = this.ctx.attempts <= 2;
this.ctx.linkUp = ok;
return ok;
}

async deliver(payload: string): Promise<boolean> {
if (!this.ctx.linkUp) {
return false;
}
this.ctx.lastPayload = payload;
return true;
}
}

@ihsm.InitialState
export class Down extends LinkTop {}

export class Connecting extends LinkTop {
onEntry(): void {
this.notifyNow.finishDial();
}

finishDial(): void {
this.ctx.attempts += 1;
const ok = this.ctx.attempts <= 2;
this.ctx.linkUp = ok;
this.hsm.transition(ok ? Up : Failed);
}
}

export class Up extends LinkTop {}

export class Failed extends LinkTop {}

/** Parent gateway — spawns Link on Active entry, drops child on exit. */
export interface GatewayCtx {
host: string;
delivered: number;
link?: ChildActor<LinkConfig>;
/** Same object passed to `makeChildActor` — parent-readable child domain data. */
linkCtx?: LinkCtx;
}

export interface GatewayConfig {
context: GatewayCtx;
notifications: {
activate(host: string): void;
deactivate(): void;
};
services: {
relay(payload: string): Promise<boolean>;
};
}

export class GatewayTop extends PlaygroundTopState<GatewayConfig> {
activate(host: string): void {
this.ctx.host = host;
this.hsm.transition(Active);
}

deactivate(): void {
this.hsm.transition(Idle);
}

async relay(payload: string): Promise<boolean> {
if (!this.ctx.link) {
return false;
}
const ok = await this.ctx.link.call.deliver(payload);
if (ok) {
this.ctx.delivered += 1;
}
return ok;
}
}

@ihsm.InitialState
export class Idle extends GatewayTop {}

export class Active extends GatewayTop {
onEntry(): void {
if (!this.ctx.link) {
const linkCtx: LinkCtx = {
host: this.ctx.host,
attempts: 0,
linkUp: false,
lastPayload: '',
};
this.ctx.linkCtx = linkCtx;
this.ctx.link = ihsm.makeChildActor(ihsm.asParentActor(this), LinkTop, linkCtx);
}
this.ctx.link.notifyNow.open(this.ctx.host);
}

onExit(): void {
this.ctx.link = undefined;
this.ctx.linkCtx = undefined;
}
}

ihsm.registerStateNames(self);

export function createGateway() {
return makeTestActor(GatewayTop, { host: '', delivered: 0 });
}

18 · Chained child actors (not parallel states)

Interactive playgroundGateway + owned link child
StateGateway state: GatewayTop · link: (none) · delivered=0

19 · Request manager (table + cancellable commands)

When and why: Request manager (table + cancellable commands)

Use a manager parent actor plus per-request child command actors when you need a request table, heterogeneous command types, and cancellation while work is in flight.

Event-only IPC: submit / cancel / child.notify.start / manager.finished — no cross-actor call. Commands arm hsm.port.defer(ms).complete() so a cancel notification can win before the deferred complete; tests advance the child TestPort clock.

Versus tutorial 14: many short-lived children tracked in ctx.table instead of two long-lived region children.

State diagram

UML state diagram

Full example source

Runnable code lives under 19-request-manager. The listings below are the complete, commented sources used by the trace panel.

examples/19-request-manager/machine.ts

/**
* Request manager — parent actor with a request table and two command child types.
* Cross-actor coordination is notification-only; commands complete via deferred events
* so requests can be cancelled before completion.
*/
import * as ihsm from '../../src';
import type { ChildActor } from '../../src';
import { makeTestActor, TestPort } from '../../src/testing';
import { PlaygroundTopState } from '../shared/playground-top';
import * as self from './machine';

export type RequestKind = 'alpha' | 'beta';
export type RequestStatus = 'running' | 'done' | 'cancelled';

export interface RequestRow {
kind: RequestKind;
status: RequestStatus;
}

/** Events command children fire back to the manager (wired at spawn). */
export interface ManagerChildEvents {
finished(requestId: number): void;
cancelled(requestId: number): void;
}

export interface AlphaCtx {
requestId: number;
cancelled: boolean;
manager: ManagerChildEvents;
}

export interface AlphaConfig {
context: AlphaCtx;
internalNotifications: {
start(): void;
complete(): void;
cancel(): void;
};
}

export class AlphaTop extends PlaygroundTopState<AlphaConfig> {
start(): void {
this.hsm.transition(AlphaRunning);
}

cancel(): void {
if (this.ctx.cancelled) {
return;
}
this.ctx.cancelled = true;
this.hsm.transition(AlphaCancelled);
this.ctx.manager.cancelled(this.ctx.requestId);
}

complete(): void {
if (this.ctx.cancelled) {
return;
}
this.hsm.transition(AlphaDone);
this.ctx.manager.finished(this.ctx.requestId);
}
}

@ihsm.InitialState
export class AlphaIdle extends AlphaTop {}

export class AlphaRunning extends AlphaTop {
onEntry(): void {
this.hsm.port.defer(50).complete();
}
}

export class AlphaDone extends AlphaTop {}

export class AlphaCancelled extends AlphaTop {}

export interface BetaCtx {
requestId: number;
cancelled: boolean;
manager: ManagerChildEvents;
}

export interface BetaConfig {
context: BetaCtx;
internalNotifications: {
start(): void;
complete(): void;
cancel(): void;
};
}

export class BetaTop extends PlaygroundTopState<BetaConfig> {
start(): void {
this.hsm.transition(BetaRunning);
}

cancel(): void {
if (this.ctx.cancelled) {
return;
}
this.ctx.cancelled = true;
this.hsm.transition(BetaCancelled);
this.ctx.manager.cancelled(this.ctx.requestId);
}

complete(): void {
if (this.ctx.cancelled) {
return;
}
this.hsm.transition(BetaDone);
this.ctx.manager.finished(this.ctx.requestId);
}
}

@ihsm.InitialState
export class BetaIdle extends BetaTop {}

export class BetaRunning extends BetaTop {
onEntry(): void {
this.hsm.port.defer(50).complete();
}
}

export class BetaDone extends BetaTop {}

export class BetaCancelled extends BetaTop {}

export type CommandChild = ChildActor<AlphaConfig> | ChildActor<BetaConfig>;

export interface RequestManagerCtx {
nextId: number;
table: Record<number, RequestRow>;
children: Record<number, CommandChild>;
/** TestPort instances used to advance deferred command timers in tests. */
childPorts: Record<number, TestPort<typeof AlphaTop> | TestPort<typeof BetaTop>>;
}

export interface RequestManagerConfig {
context: RequestManagerCtx;
notifications: {
submit(kind: RequestKind): void;
cancel(requestId: number): void;
};
internalNotifications: {
commandFinished(requestId: number): void;
commandCancelled(requestId: number): void;
};
}

export class RequestManagerTop extends PlaygroundTopState<RequestManagerConfig> {
submit(kind: RequestKind): void {
const requestId = ++this.ctx.nextId;
this.ctx.table[requestId] = { kind, status: 'running' };
const manager = this.managerEvents();
if (kind === 'alpha') {
const alphaCtx: AlphaCtx = { requestId, cancelled: false, manager };
const port = new TestPort<typeof AlphaTop>();
const child = ihsm.makeChildActor(ihsm.asParentActor(this), AlphaTop, alphaCtx, port);
this.ctx.children[requestId] = child;
this.ctx.childPorts[requestId] = port;
child.notify.start();
} else {
const betaCtx: BetaCtx = { requestId, cancelled: false, manager };
const port = new TestPort<typeof BetaTop>();
const child = ihsm.makeChildActor(ihsm.asParentActor(this), BetaTop, betaCtx, port);
this.ctx.children[requestId] = child;
this.ctx.childPorts[requestId] = port;
child.notify.start();
}
}

cancel(requestId: number): void {
const row = this.ctx.table[requestId];
const child = this.ctx.children[requestId];
if (!row || row.status !== 'running' || !child) {
return;
}
child.notify.cancel();
}

commandFinished(requestId: number): void {
const row = this.ctx.table[requestId];
if (!row || row.status !== 'running') {
return;
}
row.status = 'done';
delete this.ctx.children[requestId];
delete this.ctx.childPorts[requestId];
}

commandCancelled(requestId: number): void {
const row = this.ctx.table[requestId];
if (!row || row.status !== 'running') {
return;
}
row.status = 'cancelled';
delete this.ctx.children[requestId];
delete this.ctx.childPorts[requestId];
}

private managerEvents(): ManagerChildEvents {
return {
finished: id => this.notifyNow.commandFinished(id),
cancelled: id => this.notifyNow.commandCancelled(id),
};
}
}

@ihsm.InitialState
export class ManagerIdle extends RequestManagerTop {}

ihsm.registerStateNames(self);

export function createRequestManager() {
return makeTestActor(RequestManagerTop, { nextId: 0, table: {}, children: {}, childPorts: {} }, new TestPort<typeof RequestManagerTop>());
}

/** Drain manager and every in-flight command child queue. */
export async function syncRequestManager(manager: ReturnType<typeof createRequestManager>): Promise<void> {
await manager.hsm.sync();
for (const child of Object.values(manager.ctx.children)) {
await child.hsm.sync();
}
await manager.hsm.sync();
}

19 · Request manager (table + cancellable commands)

Interactive playgroundRequest manager
StateManager: RequestManagerTop · table: (empty) · inflight=0

Learning path

  1. Read Key concepts and Tracing, then work through the interactive examples (01–19) on this page.
  2. Study Rules of thumb for integration patterns (includes tutorial 15).
  3. For multi-actor composition after tutorial 14, continue with tutorial 18 and tutorial 19.