Typed State Machine With Transition Guards
Model finite states with typed events, guarded transitions, transition history and clear errors for illegal or rejected moves.
Code
export interface Transition<State extends string, Event extends string, Context> {
from: State;
event: Event;
to: State;
guard?: (context: Context, event: Event) => boolean;
}
export interface StateMachineOptions<State extends string, Event extends string, Context> {
context: Context;
onTransition?: (next: State, previous: State, event: Event) => void;
}
export class StateMachine<State extends string, Event extends string, Context = undefined> {
private current: State;
private readonly transitions: ReadonlyArray<Transition<State, Event, Context>>;
private readonly context: Context;
private readonly onTransition?: (next: State, previous: State, event: Event) => void;
private readonly history: State[] = [];
constructor(
initial: State,
transitions: ReadonlyArray<Transition<State, Event, Context>>,
options: StateMachineOptions<State, Event, Context>,
) {
if (!Array.isArray(transitions)) throw new TypeError("transitions must be an array");
for (const transition of transitions) {
if (!transition.from || !transition.event || !transition.to) {
throw new TypeError("transition requires from, event and to");
}
if (transition.guard !== undefined && typeof transition.guard !== "function") {
throw new TypeError("guard must be a function");
}
}
this.current = initial;
this.transitions = [...transitions];
this.context = options.context;
this.onTransition = options.onTransition;
}
get state(): State {
return this.current;
}
get previousStates(): readonly State[] {
return [...this.history];
}
can(event: Event): boolean {
return this.transitions.some((transition) =>
transition.from === this.current &&
transition.event === event &&
(!transition.guard || transition.guard(this.context, event)));
}
send(event: Event): State {
const transition = this.transitions.find((candidate) =>
candidate.from === this.current && candidate.event === event);
if (!transition) {
throw new Error("no transition from " + this.current + " on " + event);
}
if (transition.guard && !transition.guard(this.context, event)) {
throw new Error("guard rejected " + this.current + " -> " + transition.to + " on " + event);
}
const previous = this.current;
this.current = transition.to;
this.history.push(this.current);
this.onTransition?.(this.current, previous, event);
return this.current;
}
}Report a problem with this exampleOpens a pre-filled issue on GitHub.