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.
| Attribute | Value |
|---|---|
| Production dependencies | 0 |
| Runtime test coverage | ≥94% lines (CI gate); target 100% |
| Node.js | 22+ |
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
- Key concepts
- Key features
- Static type checking
- Messaging: notifications, services, sync
- Transitions
- Tracing
- restore()
- Error model
- Async handlers
- Factories
- Zero dependencies
- Code coverage
- Comparison with XState
- Quick reference
- Interactive examples (01–19)
Interactive examples (01–19)
Standard tutorials (01–19, 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.
| # | Topic | Playground |
|---|---|---|
| 01 | Hello state machine | §01 |
| 02 | Tracing | §02 |
| 03 | Context | §03 |
| 04 | Protocol typing | §04 |
| 05 | Hierarchy and transitions | §05 |
| 07 | Internal transitions | §07 |
| 08 | Notifications and sync | §08 |
| 09 | Deferred post | §09 |
| 10 | call services | §10 |
| 11 | restore | §11 |
| 12 | Error recovery | §12 |
| 13 | Async handlers | §13 |
| 14 | Nested machines (parent + child regions) | §14 |
| 15 | Complex workflow | §15 |
| 17 | notifyNow | §17 |
| 18 | Chained child actors (not parallel states) | §18 |
| 19 | Request manager (table + cancellable commands) | §19 |
1. Key concepts
State as class
Each state is a class extending TopState or a parent state class. The
active state is the prototype of a single instance object — switched with
Object.setPrototypeOf when you call transition(NextStateClass).
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
| Feature | How in ihsm | Explicit in library? |
|---|---|---|
| Context | ctx on instance | Yes |
| Typed vocabulary | Config + state handlers | Yes |
| Hierarchy | class extends | Yes |
| Initial substate | @InitialState | Yes |
| Transition | this.hsm.transition(StateClass) | Yes |
| Cached LCA path | automatic | Yes (internal) |
| Entry / exit | onEntry() / onExit() | Yes |
| Internal transition | handle event, no transition() | Implicit (by omission) |
| Guards | if in handler | Implicit (code) |
| History | ctx + restore() | Implicit (data) |
| Orthogonal regions | multiple actors (not type: 'parallel') | Composition |
| Chained child actors | makeChildActor in parent onEntry | Yes |
| Request table + cancellable commands | manager parent + per-request child actors | Composition |
| Notifications | actor.notify.event() — fire-and-forget | Yes |
hsm.port.defer(ms) | port timer (Port.setTimeout) + queue | Yes |
| Services | await actor.call.service() — Promise | Yes |
hsm.sync() | drain queue | Yes |
restore | set state + ctx | Yes |
makeActor | create + optional init | Yes |
| Tracing | levels + TraceWriter | Yes |
| Errors | typed error hierarchy | Yes |
| Async handlers | async methods | Yes |
Context
Mutable domain object passed as the second argument to 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 inonExit - 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
TestPortand 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 / style | Event names | Payload types | call return type | Same run-to-completion dispatch for events + services |
|---|---|---|---|---|
| ihsm | keyof bucket literals | inferred from method params | Promise<T> from handler return | Yes |
| XState v5 | string type on objects | setup().types maps | snapshot / spawned actors / waitFor | No unified typed call |
JavaScript FSMs (e.g. vanilla switch) | runtime strings | none | callbacks / manual | N/A |
| Robot / SCXML ports | strings or enums | manual validation | ad hoc | No |
Concrete gaps elsewhere:
- Stringly-typed events —
send({ type: 'setTargt' })compiles unless you maintain a separate union and exhaustiveness checks; ihsm rejectsnotify.setTargt(…)because'setTargt'is not a key on the notifications bucket. - Untyped payloads — object events decouple payload shape from handler
signature; ihsm derives the rest parameters of
notify.setTarget(…)from thesetTargethandler onConfig.notifications. - No typed request/response on the actor — XState and peers use
getSnapshot(), child actors, or external promises; ihsm’scall.getBalance()returnsPromise<number>inferred from the service handler return type. - 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 onConfigbuckets.
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:
| Rule | Meaning |
|---|---|
1. One Config type parameter | ActorConfig (your Config bag) flows through makeActor, TopState, actor handles, and errors. |
| 2. Events are void handlers | A 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 excluded | Keys reserved on State / handlers (e.g. transition, notify, ctx, hsm) cannot be protocol keys — they become never at the type level. |
| 5. Disjoint protocol buckets | notifications, 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
Statemachinery → excluded viaFilterReservedKeys. - Non-void-return methods in the notifications bucket → payload becomes
never(prefervoidhandlers 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)
| Mistake | TypeScript error |
|---|---|
| Typo in event name | Method missing on notify facet (compile error on unknown key) |
| Wrong payload type | Argument of type 'string' is not assignable to parameter of type 'number' |
| Wrong payload count | Tuple arity mismatch on rest parameters |
Calling service with notify | Service-shaped method may yield never payload or wrong inference — use call |
Calling event with call | Request/response inference fails; return type may be never |
| Using reserved name | Event name resolves to never |
Drift between handler and Config | Mismatched handler signature vs bucket declaration; or wrong runtime dispatch |
End-to-end flow
- You define
Configwithcontextand protocol buckets. - State classes declare handler methods matching those buckets.
makeActor(TopState, ctx)infersConfigfrom the top state class.- External code calls
notify.event(…)/call.service(…)— TypeScript validates against the sameConfigthe handlers implement. - At runtime, ihsm dispatches to the method on the current state prototype chain; compile-time checks ensure the vocabulary and arity are valid at every call site.
XState: strong typing via setup().types and createMachine; events remain
{ type: 'charge', amount: 10 } objects with separate type maps — not method
signatures shared with state implementations and call-style Promise inference.
4. Messaging: notifications, services, sync
Every messaging API has two sides:
| Side | Where | Role |
|---|---|---|
| Handler | Method on the active state class | Runs when the actor dispatches the event or service |
| Client | Generated actor handle | notify, 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 element | ihsm runtime |
|---|---|
[ * ] (filled circle) | Initial pseudostate — exactly one @InitialState child per composite parent |
Rounded box / state Name { … } | State class; nested box = composite with substates |
A --> B : label | External transition — handler calls this.hsm.transition(B); LCA exit/entry runs |
StateName : event / action inside a state box | Internal transition — handler runs, no transition(), no exit/entry |
| Arrow crossing box boundary | External transition between substates or branches |
Diagram layout (PlantUML): examples use PlantUML state diagrams. To reduce overlapping transition lines when several events leave the same state:
left to right direction— default flow for most tutorial charts.- Directional arrows —
-up->,-down->,-left->,-right->(short form:-u-,-d-, …) fan arcs from one source to different targets. - Spacing —
skinparam ranksepandskinparam nodesepadd room between states. - Orthogonal lines —
skinparam linetype ortho(optional; helps some nested composites).
PlantUML still uses Graphviz auto-layout — you nudge placement with hints, not pixel-perfect control.
With left to right direction, compass keywords are interpreted before the diagram is rotated:
to place a target below the source, use -left->; above, use -right->.
Do not use self-loop arrows for internal transitions — use in-state State : event / action text instead.
After 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).
| Kind | Example (tutorial 05) | Chart notation | Exit / entry | Notes |
|---|---|---|---|---|
| Internal | tick() in LeafWestA | LeafWestA : tick / value++ inside box | none | ctx updates; state class unchanged |
| Child → sibling child | LeafWestA → LeafWestB | A --> B : goSiblingWest | exit A, enter B | LCA = parent (MidWest) |
| Child → parent composite | LeafWestA → MidWest | arrow to parent composite | exit leaf; re-enter initial leaf | Composites with @InitialState descend again |
| Child → ancestor | LeafWestB → StackWest | arrow to ancestor | exit up to LCA; enter down initial chain | Ancestors above LCA untouched |
| Child → root | LeafWestA → DeepTop | arrow to root | exit to LCA; re-enter initial branch | Root’s own onExit/onEntry skipped at LCA |
| Cross-stack leaf → leaf | LeafWestA → LeafEastB | arrow across stacks | exit west stack; enter east leaf | LCA = DeepTop |
| Cross-stack → branch composite | LeafWestA → StackEast | arrow into composite | exit source stack; enter branch + initial chain | Target composite → initial leaf |
| Cross-stack → mid composite | LeafWestA → MidEast | arrow to mid composite | same as branch when initial chain matches | Often identical trace to branch target |
| Self | LeafWestA → LeafWestA | arrow to same state (rare) | none | Source equals destination leaf |
Trace convention (tutorial 05): push enter:StateName / exit:StateName from
onEntry / onExit; handler:event from the handler. Compare with
npm run test:examples -- --grep 'Tutorial 05'.
Playground: 05 · Hierarchy and transitions
LCA algorithm (prototype chain)
States are classes; inheritance is the hierarchy. To transition from src to
dst:
- Walk
src→TopState, recording path and indexes. - Walk
dstupward until a class appears on thesrcpath — that is the LCA. - Exit states from the current leaf up to (not including) the LCA — only
classes that define their own
onExit(debug/verbose trace lists). - Enter states from the LCA down toward
dst; ifdstis composite, follow each@InitialStateuntil the deepest initial leaf. - Set
currentStateto that final leaf class.
Paths are cached per FromState=>ToState in the RuntimeTransitionResolver cache.
Sync vs async with transitions
| Pattern | Behavior |
|---|---|
Sync handler + transition() | Handler completes → transition runs in same dispatch → sync() sees final state |
async handler + await + transition() | Transition runs after await; sync() waits for both |
this.notify.e() inside handler | Deferred until current handler and its transition finish |
transition() in onEntry / onExit | Cleared 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
| Failure | Error type | Default outcome |
|---|---|---|
| Handler throws | EventHandlerError | onError → often FatalErrorState |
| No handler | UnhandledEventError | onUnhandled → onError |
onExit / onEntry throws | TransitionError | Recovery → FatalErrorState |
onError throws | FatalError | FatalErrorState |
sync() drains the queue; with the default dispatchErrorCallback the
Promise still resolves after the machine enters FatalErrorState (the
callback throws to the console/logger, not to the caller). Override the callback
to propagate failures to application code.
Rules of thumb
- Called from event handlers (or recovery hooks).
- Deferred until handler completes successfully.
- Cleared if handler throws (unless recovered).
- Self-transition: no exit/entry when source equals target leaf and initial descent unchanged.
transition()insideonEntry/onExitof the same dispatch is cleared when that dispatch finishes — schedule follow-up work withthis.notifyfromonEntry, or branch in the event handler (see tutorial 15).
Playground: 15 · Complex workflow
6. Tracing
Trace levels
| Level | Value | Use |
|---|---|---|
PRODUCTION | 0 | Minimal overhead |
DEBUG | 1 | Transition and handler boundaries |
VERBOSE_DEBUG | 2 | Lookup walks, cache hit/miss |
Set trace level 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:
| Bundler | Setting |
|---|---|
| esbuild | keepNames: true |
| terser | keep_classnames: true |
| webpack (TerserPlugin) | terserOptions: { keep_classnames: true } |
| Rollup (terser plugin) | terser({ keep_classnames: true }) |
Option 2 — register display names (no enumeration)
registerStateNames reads a stable name from each export key, which
minifiers preserve even when they mangle the class identifiers. The ergonomic
way is to register the module's own namespace — no need to list every state:
// machine.ts
import * as ihsm from 'ihsm';
import * as self from './machine'; // self-reference
export class DoorTop extends ihsm.TopState<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 withminify: 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
| Type | When |
|---|---|
UnhandledEventError | No handler for event in current state |
EventHandlerError | Handler threw |
InitializationError | onEntry during init failed |
FatalError | onError recovery failed |
InitialStateError | Two @InitialState on same parent |
Hooks:
onUnhandled(error)— default throws; override to recover or redirectonError(error)— default rethrows; override to log and transition
Fatal error state: FatalErrorState when transition recovery fails.
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
| Factory | Returns | Use when |
|---|---|---|
makeActor(top, ctx, portOrOptions?, options?) | ExternalActor<C> | Production — public notify / notifyNow / call only |
makeChildActor(parent, childTop, ctx, portOrOptions?, options?) | ChildActor<C> + parent | Nested 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
| Metric | CI floor | Target |
|---|---|---|
| Statements | 94% | 100% |
| Branches | 85% | 100% |
| Functions | 88% | 100% |
| Lines | 94% | 100% |
All three dispatch implementations (production, debug, verbose) are exercised.
Tutorial tests: npm run test:examples
13. Comparison with XState
| Concern | ihsm | XState v5 |
|---|---|---|
| State definition | classes | createMachine config |
| Hierarchy | extends | nested states: |
| Events | Config + typed methods | { type: '...' } objects |
| Internal transition | omit transition() | internal: true transition |
| Guards | inline code | guard property |
| Parallel regions | multiple Hsm | type: 'parallel' |
| History | ctx / restore() | history pseudo-states |
| Async work | async handlers | invoke, actors |
| Request/response | actor.call → Promise | snapshot / spawned promises |
| Visualization | IDE + (future extract) | Stately editor |
| Dependencies | 0 | 0 (core) |
| Coverage | 100% runtime | project tests |
Choose ihsm when domain logic is class-oriented, typed services matter, and you want a tiny embeddable runtime. Choose XState when you need declarative visual specs, parallel regions in one chart, or frontend ecosystem integration.
14. Quick reference
Actor handles
| Surface | Delivery | Returns | Example |
|---|---|---|---|
actor.notify | default FIFO queue | void | door.notify.open() |
actor.notifyNow | priority queue | void | door.notifyNow.lockInventory() |
actor.call | service dispatch | Promise<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)
| Member | Description |
|---|---|
transition(next) | Schedule state change |
port | Outbound boundary — defer(ms) for timed self-notifications; setTimeout / setInterval for delays |
currentState / currentStateName | Active state |
ctx | Domain 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)
| Member | Description |
|---|---|
sync() | Drain pending work |
restore(state, ctx) | Rehydrate without entry/exit |
currentState / currentStateName | Active state |
Ports & testing
| Type | Role |
|---|---|
Port<typeof TopState> | Production port: timers + random |
TestPort<typeof TopState> | Virtual clock, mocked random, send / record |
@mock + makeTestPort | Typed domain port doubles for tests |
Errors
| Class | When |
|---|---|
UnhandledEventError | No handler in current state |
EventHandlerError | Handler threw |
InitializationError | Init onEntry failed |
FatalError | onError recovery failed |
InitialStateError | Duplicate @InitialState |
FatalErrorState | Terminal recovery-failure state |
Trace levels
| Name | Value |
|---|---|
TraceLevel.PRODUCTION | 0 |
TraceLevel.DEBUG | 1 |
TraceLevel.VERBOSE_DEBUG | 2 |
InitialState(StateClass)
Mark default substate of composite parent.
defineStateName(StateClass, name)
Assign a stable display name to one state class so traces, error messages, and
currentStateName survive minification. See
§6 State display names.
registerStateNames(exports)
Register display names in bulk from an exports map (export key → state class); non-state values are ignored. Recommended for minified browser bundles. See §6 State display names.
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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 (beginPayment → paymentDone → beginShipping).
Multi-queue sync: each actor has its own FIFO — drain parent and children (syncOrderRegions) after driving events in tests.
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)
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
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
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
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
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
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)
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
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)
Learning path
- Read Key concepts and Tracing, then work through the interactive examples (01–19) on this page.
- Study Rules of thumb for integration patterns (includes tutorial 15).
- For multi-actor composition after tutorial 14, continue with tutorial 18 and tutorial 19.