Typed State Machine With Transition Guards

Model finite states with typed events, guarded transitions, transition history and clear errors for illegal or rejected moves.

TypescriptCli Tooling

Code

snippet
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;
  }
}