States & transitions
States are classes, the hierarchy is class inheritance, events are methods, and a
transition is this.hsm.transition(Next).
States, transitions, and the initial state
import { InitialState, makeActor, Port, registerStateNames, TopState } from 'ihsm';
interface DoorCtx {
openCount: number; // survives across states for the actor's lifetime
}
interface DoorConfig {
context: DoorCtx;
notifications: { open(): void; close(): void };
}
class DoorTop extends TopState<DoorConfig> {}
@InitialState // the leaf entered after makeActor + sync
class Closed extends DoorTop {
open(): void {
this.ctx.openCount += 1;
this.hsm.transition(Open); // exit Closed, enter Open
}
}
class Open extends DoorTop {
close(): void {
this.hsm.transition(Closed);
}
}
registerStateNames({ DoorTop, Closed, Open });
const door = makeActor(DoorTop, { openCount: 0 });
await door.hsm.sync();
door.notify.open();
await door.hsm.sync();
console.log(door.hsm.currentStateName); // 'Open'
open() is defined only on Closed, so posting open while Open is a no-op delegated up the
chain to DoorTop (which does not handle it) — it is silently ignored, not an error.
Context: state that survives transitions
ctx is mutable data owned by the actor for its whole lifetime. Mutating it without calling
transition() is an internal transition — the active state does not change and onEntry/onExit
do not run.
import { InitialState, makeActor, Port, registerStateNames, TopState } from 'ihsm';
interface CounterCtx {
value: number;
step: number;
}
interface CounterConfig {
context: CounterCtx;
notifications: { increment(): void; decrement(): void };
}
class CounterTop extends TopState<CounterConfig> {
increment(): void {
this.ctx.value += this.ctx.step; // no transition() → stays in Running
}
decrement(): void {
this.ctx.value -= this.ctx.step;
}
}
@InitialState
class Running extends CounterTop {}
registerStateNames({ CounterTop, Running });
const counter = makeActor(CounterTop, { value: 0, step: 5 });
await counter.hsm.sync();
counter.notify.increment();
counter.notify.increment();
await counter.hsm.sync();
console.log(counter.ctx.value); // 10 — and still in Running
Internal vs. external transitions
onEntry runs only when a state is entered. An internal transition (no transition() call)
proves the difference.
import { InitialState, makeActor, Port, registerStateNames, TopState } from 'ihsm';
interface LampCtx {
brightness: number;
entryCount: number; // increments only on entry
}
interface LampConfig {
context: LampCtx;
notifications: { dim(delta: number): void };
}
class LampTop extends TopState<LampConfig> {
onEntry(): void {
this.ctx.entryCount += 1;
}
dim(delta: number): void {
this.ctx.brightness = Math.max(0, this.ctx.brightness - delta); // internal — no re-entry
}
}
@InitialState
class On extends LampTop {}
registerStateNames({ LampTop, On });
const lamp = makeActor(LampTop, { brightness: 100, entryCount: 0 });
await lamp.hsm.sync();
lamp.notify.dim(20);
lamp.notify.dim(20);
await lamp.hsm.sync();
console.log(lamp.ctx.brightness, lamp.ctx.entryCount); // 60 1
Hierarchy: nesting and inherited dispatch
Nest states with extends. A handler defined on a parent is inherited by every descendant;
entering a composite state continues into its @InitialState leaf.
import { InitialState, makeActor, Port, registerStateNames, TopState } from 'ihsm';
interface MediaCtx {
trail: string[];
}
interface MediaConfig {
context: MediaCtx;
notifications: { play(): void; pause(): void; stop(): void };
}
class MediaTop extends TopState<MediaConfig> {
stop(): void {
this.hsm.transition(Stopped); // inherited by every state below
}
}
@InitialState
class Stopped extends MediaTop {
onEntry(): void {
this.ctx.trail.push('enter:Stopped');
}
play(): void {
this.hsm.transition(Playing);
}
}
class Active extends MediaTop {
onEntry(): void {
this.ctx.trail.push('enter:Active');
}
}
@InitialState // initial leaf entered when Active is the target
class Playing extends Active {
onEntry(): void {
this.ctx.trail.push('enter:Playing');
}
pause(): void {
this.hsm.transition(Paused);
}
}
class Paused extends Active {
onEntry(): void {
this.ctx.trail.push('enter:Paused');
}
play(): void {
this.hsm.transition(Playing);
}
}
registerStateNames({ MediaTop, Stopped, Active, Playing, Paused });
const media = makeActor(MediaTop, { trail: [] });
await media.hsm.sync();
media.notify.play();
await media.hsm.sync();
console.log(media.hsm.currentStateName); // 'Playing'
console.log(media.ctx.trail); // ['enter:Stopped', 'enter:Active', 'enter:Playing']
Transitioning to Active enters Active then its initial leaf Playing; the lowest common
ancestor (MediaTop) bounds which states exit and enter.