npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@thingts/fsm-engine

v1.2.1

Published

TypeScript finite state machine engine with a readable DSL, strong typing, and well-defined runtime semantics

Readme

@thingts/fsm-engine

npm version docs GitHub Actions Workflow Status GitHub License bundle size

A TypeScript finite-state machine engine with a readable builder DSL, strong typing, eager validation, and clear, well-defined runtime semantics for event dispatch and action execution.

Why?

There are times when the logic of a system is most clearly expressed as a finite-state machine (FSM): a collection of discrete states, with rules for how the system transitions between them in response to events, and what actions it performs. Examples include UI components with complex interaction patterns, network request lifecycles, game AI, and more.

This package provides a clean, ergonomic DSL for defining FSMs, with clear runtime semantics for event dispatch and action execution. Features include:

  • Readable FSM definitions -- states, events, guards, and actions are declared using a DSL designed for clarity and ease of use
  • Scales from simple to complex — the DSL supports concise, pithy definitions for simple machines and structured, readable definitions for complex ones
  • Strong typing throughout -- event names, payloads, state names, and context are inferred and checked by TypeScript, both in the machine definition and at event dispatch call sites
  • Errors caught early -- validates the machine at define() time, throws errors for invalid definitions (e.g. unreachable states, duplicate declarations, etc.)
  • Complete but not heavy -- guards, actions, entry/exit actions, internal/external transitions, and fallbacks
  • Re-entrant-safe event processing -- events dispatched during processing are safely queued and handled in order
  • Debugging support -- trace logs show exactly which guards, transitions, and actions run for each event
  • Small and dependency-free

Concepts

A machine is defined once with FsmEngine.define(), which returns a factory function for creating multiple independent instances. Each instance has its own context object (your mutable state), if the definition requires one.

  • State — a named mode the machine can be in (e.g. 'idle', 'loading', 'done')
  • Event — a named trigger with an optional typed payload (e.g. { click: void, setValue: number })
  • Transition — when in state S and event E fires, move to state T (external) or stay in S (internal)
  • Guard — a predicate on a transition; if it returns false, that transition is skipped and the next is tried (first match wins)
  • Transition action — a callback that runs when a transition fires
  • Begin action — runs at the start of event processing, before any transition is evaluated; useful for mutations or precomputations that apply regardless of which transition will fire
  • Entry / exit action — runs when entering or leaving a state
  • Fallback — catch-all event rules that apply in any state when the event isn't handled by state-specific rules

Installation

npm install @thingts/fsm-engine

Quick start

(Full API reference: https://thingts.github.io/fsm-engine/)

The classic simple turnstile FSM — two states, two events:

Diagram: Wikimedia Commons, public domain

import { FsmEngine } from '@thingts/fsm-engine'

type State  = 'locked' | 'unlocked'
type Events = { coin: void, push: void }

const makeTurnstile = FsmEngine.define<State, Events>(b => {
  b.state('locked', s => {
    s.onEnter(() => console.log('Turnstile is now locked'))
    s.event('coin', e => e.toState('unlocked'))
    s.event('push', e => e.stay()) // pushed while locked: stay locked
  })

  b.state('unlocked', s => {
    s.onEnter(() => console.log('Turnstile is now unlocked'))
    s.event('push', e => e.toState('locked'))
    s.event('coin', e => e.stay()) // coin while unlocked: stay unlocked
  })
})

const turnstile = makeTurnstile() // "Turnstile is now locked" (initial state entry action)
turnstile.dispatch('coin')  // "Turnstile is now unlocked"
turnstile.dispatch('push')  // "Turnstile is now locked"

A slightly more complex example with guards and context:

type State = 'idle' | 'loading' | 'success' | 'failed'
type Events = {
  submit: { query: string }
  resolve: { result: string }
  reject: { message: string }
  reset: void
}
type Context = {
  query: string
  result: string | null
  error: string | null
}

const makeSearchFsm = FsmEngine.define<State, Events, Context>(b => {
  b.fallback('reset', e => e.toState('idle'))

  b.state('idle', s => {
    s.event('submit', e => {
      e.begin(({ payload, context }) => {
        context.query = payload.query.trim()
        context.result = null
        context.error = null
      })
      e.when(({ context }) => context.query.length > 0)
        .toState('loading', t => t.action(({ fsm, context }) => {
          performSearch(context.query)
            .then(result => fsm.dispatch('resolve', { result }))
            .catch(error => fsm.dispatch('reject', { message: String(error) }))
        }))
    })
  })

  b.state('loading', s => {
    s.event('resolve', e => e.toState('success', t => {
      t.action(({ payload, context }) => { context.result = payload.result })
    }))
    s.event('reject', e => e.toState('failed', t => {
      t.action(({ payload, context }) => { context.error = payload.message })
    }))
  })

  b.state('success', s => {})
  b.state('failed', s => {})
})

For more complex scenarios, see approaches in the Patterns section.


DSL reference

The DSL is structured around a small set of nested builders, each with a focused API for declaring different aspects of the machine.

The sections below follow this structure, from the top-level machine definition down to individual transitions and actions.

In all cases, the APIs are strongly typed, with types inferred from your type parameters. For example, if your Events type has a submit event with a { query: string } payload, the payload parameter in actions and guards for that event will be typed accordingly. If you declare an action with the wrong payload type, TypeScript will catch the mismatch at compile time.

FsmEngine.define<State, Events, Context?>(b => { ... }) {#define}

Defines the machine and returns a factory function for creating instances. Takes up to three type parameters:

  • State — a union of string literals, e.g. 'idle' | 'loading' | 'done'
  • Events — an object mapping event names to payload types, e.g. { go: void, setValue: number }
  • Context — optional; the type of the mutable context object passed to guards and actions

The body receives an FSM builder. Definition errors are thrown eagerly here — see Definition errors.

See FsmEngine.define in the API reference for details.


FSM builder {#fsm-builder}

FsmEngine.define<State, Events, Context>(b => {
  b.name('MyMachine')    // used in trace output (optional)
  b.initialState(name)   // defaults to first declared state (optional)
  b.state(name, ...)     // declare states (at least one required)
  b.fallback(event, ...) // declare fallback event rules (zero or more)
})

b.name(name) {#b-name}

Sets a human-readable name for the machine, used in trace output. Defaults to 'FsmEngine' if not set.

b.initialState(name)

Sets the initial state explicitly. By default, the first declared state is the initial state. Can be called before or after the state declaration itself.

b.state(name, s => { ... })

Declares a state. The body receives a state builder.

b.fallback(event, e => { ... }) and b.fallback([...events], e => { ... }) {#b-fallback}

Declares event rules that apply in any state, when the event wasn't otherwise handled. The body receives an event builder, same as s.event().

Fallback transitions (and their begin actions) are only evaluated after all state-specific rules have been evaluated and no transition was selected.

b.fallback('reset', e => {
  e.toState('idle')
})

State builder {#state-builder}

b.state('idle', s => {
  s.onEnter(action)   // runs when entering this state (zero or more)
  s.onExit(action)    // runs when leaving this state (zero or more)
  s.event(name,  ...) // event handler (zero or more)
})

// Pithy form for a single event handler:
b.state('idle', s => s.event('go', e => { ... }))

s.event(name, e => { ... }) and s.event([...names], e => { ... }) {#s-event}

Declares event handling rules for a state. Pass an array of event names to share the same handler body across multiple events — payload types are narrowed per-event inside actions and guards.

s.event(['save', 'cancel'] as const, e => {
  e.toState('idle', t => t.action(({ event, payload }) => {
    // event is 'save' | 'cancel', payload is narrowed accordingly
  }))
})

s.onEnter(action)

Declares an action to run when this state is entered. Multiple calls allowed; run in declaration order. Entry actions receive:

s.onEnter(({ state, context, fsm, meta }) => { ... })
  • state — the state being entered
  • context — the mutable context object
  • fsm — the FSM action API
  • meta{ cause, fromState, transitionKind, event, payload }

If the initial state of the FSM has entry actions, they run when the instance is created. In this case, meta.cause is 'initial', and fromState, transitionKind, and event are undefined.

For all subsequent entries, meta.cause is 'transition', and the other properties are populated accordingly.

s.onExit(action)

Declares an action to run when this state is exited. Multiple calls allowed; run in declaration order. Exit actions receive:

s.onExit(({ state, context, fsm, meta }) => { ... })
  • state — the state being exited
  • context — the mutable context object
  • fsm — the FSM action API
  • meta{ toState, transitionKind, event, payload }

Event builder {#event-builder}

s.event('go', e => {
  e.begin(action)                    // runs before any transition (zero or more)
  e.when(guard).toState('active')    // guarded external transitions (zero or more)
  e.when(guard).when(guard2).stay() 
  e.toState('other')                 // unguarded external transition
  e.stay()                           // unguarded internal transition
})

// Pithy form for a single transition:
s.event('go', e => e.toState('active', t => { ... }))

Transitions are evaluated in declaration order, selecting the first "match", i.e. a guarded transition whose guards all pass, or an unguarded transition. Any number of guarded transitions may be declared for an event in any order, but if an unguarded transition is declared it must be last (since it always matches).

If no transition matches (all guards fail, no unguarded transition, and no fallback transition selected), the machine implicitly stays with no actions. This makes it easy to use guards to filter out ineligible events, e.g.:

s.event('go', e => e.when(({ context }) => context.isReady).toState('active'))

e.begin(action)

Runs before any transition is evaluated for this event. Multiple calls allowed; run in declaration order. Begin actions receive:

e.begin(({ event, payload, context, fsm, meta }) => { ... })
  • event — the event name
  • payload — the event payload, typed per the event definition
  • context — the mutable context object
  • fsm — the FSM action API
  • meta{ state }

Begin actions are useful for mutations or precomputations that should happen regardless of which transition will fire:

s.event('go', e => {
  e.begin(({ context, payload }) => { context.isEven = payload.count % 2 === 0 })
  e.when(({ context }) => context.isEven).toState('even')
  e.when(({ context }) => !context.isEven).toState('odd')
})

e.toState(targetState, t => { ... }) {#e-tostate}

Declares an external transition to targetState. The optional body receives a transition actions builder to declare transition actions.

targetState can be the current state, causing the machine to exit and re-enter the state, meaning that the exit and entry actions will run. See event handling lifecycle for details on the order of actions.

e.stay(t => { ... }) {#e-stay}

Declares an internal transition — stays in the current state. The optional body receives a transition actions builder to declare transition actions.

e.when(guard)....

Declares a guard, and returns a builder to optionally chain more guards, and finally the guarded transition (toState or stay). If multiple guards are chained, they are evaluated in order and all must pass for the transition to be selected.

Each guard is a predicate function that receives:

e.when(({ event, payload, context, meta }) => boolean)
  • event, payload, context — same as transition actions
  • meta{ state, transitionKind }

Transition actions builder {#transition-actions-builder}

e.toState('active', t => {
  t.action(action)  // one or more; run in declaration order
})

// Pithy form for a single action:
e.toState('active', t => t.action(action))

t.action(action)

Adds a transition action to run when this transition fires. Multiple calls allowed; run in declaration order. Transition actions receive:

t.action(({ event, payload, context, fsm, meta }) => { ... })
  • event — the event name
  • payload — the event payload, typed per the event definition
  • context — the mutable context object
  • fsm — the FSM action API
  • meta{ fromState, toState, transitionKind }

Runtime behavior

Event handling lifecycle {#event-handling-lifecycle}

When fsm.dispatch(event, payload?) is called:

  1. The event is enqueued. If the machine is already processing an event, dispatch() returns immediately — the new event will be processed when the current one finishes.

  2. Otherwise the machine drains the queue one event at a time:

    • Throw a runtime error if no handling is available for the event in the current state.
    • Run state-specific begin actions, if any
    • Evaluate state-specific transitions in order; execute the first that passes
    • If none pass: if there is a fallback for the event, run its begin actions if any, then evaluate its transitions
    • If still none pass: stay in the current state with no actions

    Note the difference between "no transitions are defined for the event in this state or in a fallback", which throws an error, vs. "transitions are defined, but none of them match", which results in the machine implicitly staying in the current state.

  3. For an executed external transition:

    1. Run exit actions of the current state
    2. Run the transition's actions
    3. Commit the new state
    4. Run entry actions of the new state

    For an executed internal transition:

    • Run the transition's actions
  4. If an error is thrown by any action or guard:

    • The event queue is cleared
    • The error propagates out of the outermost dispatch() call
    • If thrown during a guard or exit or transition actions, state remains at the current state.
    • If thrown during entry actions, state is already committed to the new state.

    Best practice is to catch errors inside actions and handle them appropriately (see also the error handling pattern).

Note: All actions and guards receive a meta object with information about why the function is being called, but this is intended for logging and debugging purposes only; it should not be used for logic or control flow, as such logic properly belongs in the state machine definition itself.

Re-entrant dispatch {#re-entrant-dispatch}

Events dispatched from within actions (via the fsm action API or by calling dispatch() on the instance) are enqueued and processed after the current event completes, in FIFO order. Re-entrant dispatch is always safe — there are no race conditions or inconsistent intermediate states.


Instance API

const makeFsm = FsmEngine.define<State, Events, Context>(b => { ... })

const fsm = makeFsm({ context: { ... } }) // create an instance
fsm.dispatch(event, payload?)             // dispatch an event
fsm.trace()                               // enable trace logging
fsm.context                               // the context object

Instantiation {#instantiation}

Call the factory returned by FsmEngine.define() to create an instance. If the definition includes a Context type, the factory function requires an object with a context property of that type.

// With context and optional label (for trace output):
const makeFsm = FsmEngine.define<State, Events, Context>(b => { ... })
const fsm = makeFsm({ context: { ... }, label: 'instance-1' })

// Without context, with optional label:
const makeFsm = FsmEngine.define<State, Events>(b => { ... })
const fsm = makeFsm({ label: 'instance-1' })

// Without context or label, no argument needed:
const makeFsm = FsmEngine.define<State, Events>(b => { ... })
const fsm = makeFsm()

fsm.dispatch(event, payload?) {#instance-dispatch}

Dispatches an event to the machine. The event name must be a key of the Events type; the payload must match its type (or be omitted if void).

Throws an error if there is no handling available for the event in the current state.

See FsmEngine#dispatch in the API reference for details.

fsm.trace(enable = true) {#trace}

Enables trace logging to console.log. Each event, guard evaluation, transition, and action invocation is logged with a timestamp and machine name (if set via b.name()) and label (if set at instantiation). Pass false to disable. Returns this for chaining:

const fsm = makeFsm({ context, label: 'instance-1' }).trace()

Named guard and action functions appear by name in the trace — see the named helper functions pattern.

You can also specify a logger function to receive trace output instead of console.log; see FsmEngine#trace in the API reference for details.


FSM action API {#fsm-action-api}

The fsm parameter in action callbacks provides effects for use within the FSM:

t.action(({ fsm }) => {
  fsm.dispatch('eventName', payload?)  // enqueue an event
  fsm.timer({ event, ms })             // schedule a future event (delay in ms)
  fsm.timer({ event, deadline })       // schedule at absolute performance.now() time
  fsm.cancelTimer(key)                 // cancel a timer by key
  fsm.cancelAllTimers()                // cancel all timers
})

fsm.dispatch(event, payload?)

Enqueues an event for processing after the current event completes (see "Re-entrant Dispatch". Same signature and type safety as the instance dispatch(). Never throws.

fsm.timer({ key?, event, ms | deadline, payload? })

Schedules a future dispatch() of event. ms is a delay in milliseconds; deadline is an absolute time in the performance.now() domain. Either may be Infinity to create a timer that never fires.

key defaults to the event name and identifies the timer for cancellation and replacement — scheduling with the same key cancels the existing timer and replaces it with the new one. To use multiple timers for the same event, provide unique keys, e.g.:

fsm.timer({ key: 'timeout-short', event: 'timeout', ms: 500 })
fsm.timer({ key: 'timeout-long', event: 'timeout', ms: 10000 })

To associate a timer with a specific state, see the "state with timeout" pattern.

fsm.cancelTimer(key)

Cancels the timer with the given key. No-op if not found.

fsm.cancelAllTimers()

Cancels all active timers. Useful for cleanup or reset.


Errors

All error classes extend FsmEngineError and support instanceof checks.

Structural machine structure errors are caught at define() time; runtime errors are reserved for truly invalid use of the machine, i.e. dispatching an event that cannot be handled in the current state.

Definition errors {#definition-errors}

Thrown by FsmEngine.define(), all extending FsmEngineDefinitionError:

| Error | Cause | |---|---| | FsmEngineNoDefinitionError | No states defined | | FsmEngineUnknownInitialStateError | b.initialState() references an undeclared state | | FsmEngineUnknownTransitionStateError | e.toState() references an undeclared state | | FsmEngineUnreachableStateError | A state has no path from the initial state | | FsmEngineUnreachableTransitionError | A transition is preceded by an unconditional one | | FsmEngineMultipleStateDefinitionsError | Same state declared twice | | FsmEngineMultipleEventDefinitionsError | Same event declared twice in one state or fallback |

Note: states or events declared in your types but never used in the builder are silently valid — TypeScript cannot detect this.

Runtime errors {#runtime-errors}

Thrown by fsm.dispatch(), both extending FsmEngineRuntimeError:

| Error | Cause | |---|---| | FsmEngineUnknownEventError | Event not declared in any state or fallback | | FsmEngineUnhandledEventError | No transition defined in the current state and no fallback defined |


Patterns {#patterns}

Pattern: Fsm type alias {#pattern-fsm-type-alias}

Alias the instance type with your types bound to the engine's generics:

type State   = 'idle' | 'active' | 'done'
type Events  = { go: void, finish: { result: string } }
type Context = { result: string | null }

export type MyFsm = FsmEngine<State, Events, Context>

function doSomething(fsm: MyFsm) { ... }

Pattern: Type aliases for actions and guards {#pattern-type-aliases}

@thingts/fsm-engine exports named types for all callback types. Collapse the generics with local aliases bound to your machine's types:

import type { FsmTransitionAction, FsmBeginAction, FsmEntryAction, FsmExitAction, FsmGuard } from '@thingts/fsm-engine'

type EventName = keyof Events

type Action<E extends EventName = EventName>      = FsmTransitionAction<State, Events, E, Context>
type BeginAction<E extends EventName = EventName> = FsmBeginAction<State, Events, E, Context>
type EntryAction                                  = FsmEntryAction<State, Events, Context>
type ExitAction                                   = FsmExitAction<State, Events, Context>
type Guard<E extends EventName = EventName>       = FsmGuard<State, Events, E, Context>

Pattern: Named helper functions {#pattern-named-helper-functions}

For non-trivial machines, define guards and actions as named constants. This keeps the DSL concise and scannable. As a bonus, TypeScript/JavaScript assigns the variable name to the function, so named helpers appear by name in trace output.

Building on the type alaises pattern:

const saveResult:  Action<'finish'> = ({ payload, context }) => { context.result = payload.result }
const isReady:     Guard            = ({ context })          => context.result !== null
const recordState: EntryAction      = ({ state, context })   => { context.currentState = state }

const makeFsm = FsmEngine.define<State, Events, Context>(b => {
  b.state('idle', s => {
    s.onEnter(recordState)
    s.event('go', e => e.when(isReady).toState('active'))
  })
  b.state('active', s => {
    s.onEnter(recordState)
    s.event('finish', e => e.toState('done', t => t.action(saveResult)))
  })
  b.state('done', s => s.onEnter(recordState))
})

Pattern: State with timeout {#pattern-state-with-timeout}

A common FSM pattern is to have a state that should only be active for a certain amount of time, after which the machine should transition to another state. This can be implemented using entry and exit actions:

b.state('waiting', s => {
  s.onEnter(({ fsm }) => fsm.timer({ event: 'timeout', ms: 5000 }))
  s.onExit(({ fsm })  => fsm.cancelTimer('timeout'))

  s.event('input',   e => e.toState('processing'))
  s.event('timeout', e => e.toState('idle'))
})

The timer is set on entry and cancelled on exit, regardless of which event caused the exit.

Pattern: Error handling {#pattern-error-handling}

A recommended approach is to catch errors inside actions and dispatch an error event, keeping error handling within the FSM's own event model. Define an error event and a fallback to handle it from any state:

type Events = {
    ...
    error: { error: Error, origin: Parameters<Action>[0] } // using Type aliases pattern
}

b.fallback('error', e => {
  e.toState('failed', t => t.action(({ payload }) => {
    console.error(`Error in event ${payload.origin.event}:`, payload.error)
  }))
})

A higher-order wrapper makes any action error-safe:

function safe<T extends Action>(action: T): T {
  return (params => {
    try { action(params) }
    catch (reason) {
      params.fsm.dispatch('error', { reason, origin: params })
    }
  }) as T
}

Use it at the call site or at definition time:

// At call site:
e.toState('processing', t => t.action(safe(riskyAction)))

// Or wrap at definition time using the named helper pattern:
const riskyAction = safe(({ payload }) => doSomethingRisky(payload))

Contributing

Contributions are welcome!

As usual: fork the repo, create a feature branch, and open a pull request, with tests and docs for any new functionality. Thanks!