ts ex machina - finite state machines in TypeScript
See the codeFocused finite state machines for JavaScript and TypeScript. States in, states out.
v7 is current. The v6 line redesigned the API: cleaner handler signatures, TypeScript-first, and a BehavioralFsm that tracks state per-client instead of stamping properties on your objects. v7 tightens the type checking (typos in previously-unchecked configs now surface as compile errors), makes hierarchical delegation reach any depth, and adds bubbles, typed declarations of the inputs an FSM sends up to whatever mounts it. v8 adds typed input payloads: declare each input's argument tuple once, and handlers and handle() calls are checked against it. See the v6 → v7 migration notes.
npm install machina
# or
pnpm add machina
A door lock that only unlocks with the right code:
import { createFsm } from "machina";
const lock = createFsm({
id: "door-lock",
initialState: "locked",
context: { attempts: 0 },
states: {
locked: {
submit({ ctx }, code: unknown) {
ctx.attempts++;
if (code === "1234") {
return "unlocked";
}
if (ctx.attempts >= 3) {
return "alarming";
}
},
},
unlocked: {
lock: "locked",
},
alarming: {
reset({ ctx }) {
ctx.attempts = 0;
return "locked";
},
},
},
});
lock.handle("submit", "wrong"); // stays in "locked"
lock.handle("submit", "1234"); // transitions to "unlocked"
lock.handle("lock"); // back to "locked"
console.log(lock.currentState()); // "locked"
Handlers return a state name to transition, or nothing to stay put. String shorthand (lock: "locked") handles the simple cases.
createFsm — single-clientThe FSM owns its own context. Use this when you have one instance of a thing.
const fsm = createFsm({
id: "my-fsm",
initialState: "idle",
context: { count: 0 },
states: {
idle: {
start: "running",
},
running: {
stop: "idle",
tick({ ctx }) {
ctx.count++;
},
},
},
});
fsm.handle("start");
fsm.handle("tick");
console.log(fsm.currentState()); // "running"
createBehavioralFsm — multi-clientThe FSM defines behavior; state is tracked per-client object in a WeakMap. One FSM definition, any number of independent clients.
import { createBehavioralFsm } from "machina";
interface Connection {
url: string;
retries: number;
}
const connFsm = createBehavioralFsm<Connection>()({
id: "connectivity",
initialState: "disconnected",
states: {
disconnected: { connect: "connecting" },
connecting: {
connected: "online",
failed({ ctx }) {
ctx.retries++;
return "disconnected";
},
},
online: { disconnect: "disconnected" },
},
});
const connA = { url: "wss://a.example.com", retries: 0 };
const connB = { url: "wss://b.example.com", retries: 0 };
connFsm.handle(connA, "connect"); // connA: "connecting"
connFsm.handle(connB, "connect"); // connB: "connecting"
connFsm.handle(connA, "connected"); // connA: "online"
connFsm.handle(connB, "failed"); // connB: "disconnected", retries: 1
Hierarchical states — nest child FSMs inside parent states via _child. Inputs delegate to the child first and bubble up on nohandler.
Deferred input — call defer() inside a handler to re-queue the input for replay after the next transition. Optionally target a specific state with defer({ until: "someStateName" }).
Lifecycle hooks — _onEnter and _onExit on any state. Returning a state name from _onEnter causes an immediate bounce transition.
Event emission — subscribe to built-in lifecycle events (transitioning, transitioned, handling, handled, nohandler, deferred) or emit your own from handlers via the emit handler arg.
TypeScript-first — state names, input names, and transition targets are all inferred from your config. Typos in string shorthand are compile errors.
Typed input payloads: declare an input payload map (createFsm<TInputs>()({...})) and handler payload parameters are inferred from it, handle() enforces payloads at the call site, and undeclared handler keys become compile errors. Opt-in and type-only: the untyped forms are unchanged. See the typed payloads guide.
Disposal — dispose() is permanent, irreversible, and cascades to child FSMs by default. All post-dispose calls are silent no-ops.
machina-inspect — Static analysis for FSM configs. Parses configs into a directed graph IR and runs structural checks (unreachable states, _onEnter loops, missing handlers). Use it programmatically or as the engine behind the tools below.
machina-test — Jest/Vitest custom matchers for testing FSM graph topology. Assert reachability, catch dead states, and verify structural invariants from your test suite. Zero ceremony beyond import "machina-test".
eslint-plugin-machina — ESLint plugin that surfaces machina-inspect findings inline in your editor. Three rules, one import to set up.
machina-explorer — Browser-based paste-and-analyze tool. Paste an FSM config, run checks, and render a mermaid state diagram. No install required.
Machina was originally inspired by the gen_fsm behavior module for Erlang/OTP. While TypeScript/JavaScript are a very different landscape than Erlang, machina seeks to preserve some of the same qualities: pragmatic, focused, minimal ceremony, and straightforward to reason about.
Other options exist — most notably XState. XState is a comprehensive statechart framework with actors, spawning, inspection tools, and SCXML compatibility. It's a phenomenal library — if you need those features, use it. Machina is for when you want a state machine (or a hierarchy of them) and nothing else.
machina.js/
packages/
machina/ # core library (npm: "machina")
machina-inspect/ # static analysis for FSM configs
machina-test/ # Jest/Vitest custom matchers for FSM testing
eslint-plugin-machina/ # ESLint plugin wrapping machina-inspect
docs/ # Astro Starlight documentation site
examples/
connectivity/ # network connectivity monitor (createFsm)
traffic-intersection/ # hierarchical FSM with child states
dungeon-critters/ # createBehavioralFsm example
shopping-cart/ # defer() showcase
job-queue/ # BehavioralFsm rehydrate() persist/restore pattern
with-react/ # React integration example
machina-explorer/ # interactive FSM inspector + diagram visualizer
testing-with-machina-test/ # machina-test matcher usage examples
Requires Node >= 22.22 and pnpm 10.29.3.
git clone https://github.com/ifandelse/machina.js.git
cd machina.js
pnpm install
pnpm turbo build
pnpm turbo test
To work on a specific package:
cd packages/machina
pnpm dev # tsdown in watch mode
pnpm test # jest
MIT + GPL-2.0 — see LICENSE
© Jim Cowart
Overall
Maintained
Code review
npm
Latest release
Releases this year
253 followers · starred Feb 2012
3,653 followers · starred May 2013
446 followers · starred Apr 2013
26 followers · starred Feb 2016
ts ex machina - finite state machines in TypeScript
See the codeFocused finite state machines for JavaScript and TypeScript. States in, states out.
v7 is current. The v6 line redesigned the API: cleaner handler signatures, TypeScript-first, and a BehavioralFsm that tracks state per-client instead of stamping properties on your objects. v7 tightens the type checking (typos in previously-unchecked configs now surface as compile errors), makes hierarchical delegation reach any depth, and adds bubbles, typed declarations of the inputs an FSM sends up to whatever mounts it. v8 adds typed input payloads: declare each input's argument tuple once, and handlers and handle() calls are checked against it. See the v6 → v7 migration notes.
npm install machina
# or
pnpm add machina
A door lock that only unlocks with the right code:
import { createFsm } from "machina";
const lock = createFsm({
id: "door-lock",
initialState: "locked",
context: { attempts: 0 },
states: {
locked: {
submit({ ctx }, code: unknown) {
ctx.attempts++;
if (code === "1234") {
return "unlocked";
}
if (ctx.attempts >= 3) {
return "alarming";
}
},
},
unlocked: {
lock: "locked",
},
alarming: {
reset({ ctx }) {
ctx.attempts = 0;
return "locked";
},
},
},
});
lock.handle("submit", "wrong"); // stays in "locked"
lock.handle("submit", "1234"); // transitions to "unlocked"
lock.handle("lock"); // back to "locked"
console.log(lock.currentState()); // "locked"
Handlers return a state name to transition, or nothing to stay put. String shorthand (lock: "locked") handles the simple cases.
createFsm — single-clientThe FSM owns its own context. Use this when you have one instance of a thing.
const fsm = createFsm({
id: "my-fsm",
initialState: "idle",
context: { count: 0 },
states: {
idle: {
start: "running",
},
running: {
stop: "idle",
tick({ ctx }) {
ctx.count++;
},
},
},
});
fsm.handle("start");
fsm.handle("tick");
console.log(fsm.currentState()); // "running"
createBehavioralFsm — multi-clientThe FSM defines behavior; state is tracked per-client object in a WeakMap. One FSM definition, any number of independent clients.
import { createBehavioralFsm } from "machina";
interface Connection {
url: string;
retries: number;
}
const connFsm = createBehavioralFsm<Connection>()({
id: "connectivity",
initialState: "disconnected",
states: {
disconnected: { connect: "connecting" },
connecting: {
connected: "online",
failed({ ctx }) {
ctx.retries++;
return "disconnected";
},
},
online: { disconnect: "disconnected" },
},
});
const connA = { url: "wss://a.example.com", retries: 0 };
const connB = { url: "wss://b.example.com", retries: 0 };
connFsm.handle(connA, "connect"); // connA: "connecting"
connFsm.handle(connB, "connect"); // connB: "connecting"
connFsm.handle(connA, "connected"); // connA: "online"
connFsm.handle(connB, "failed"); // connB: "disconnected", retries: 1
Hierarchical states — nest child FSMs inside parent states via _child. Inputs delegate to the child first and bubble up on nohandler.
Deferred input — call defer() inside a handler to re-queue the input for replay after the next transition. Optionally target a specific state with defer({ until: "someStateName" }).
Lifecycle hooks — _onEnter and _onExit on any state. Returning a state name from _onEnter causes an immediate bounce transition.
Event emission — subscribe to built-in lifecycle events (transitioning, transitioned, handling, handled, nohandler, deferred) or emit your own from handlers via the emit handler arg.
TypeScript-first — state names, input names, and transition targets are all inferred from your config. Typos in string shorthand are compile errors.
Typed input payloads: declare an input payload map (createFsm<TInputs>()({...})) and handler payload parameters are inferred from it, handle() enforces payloads at the call site, and undeclared handler keys become compile errors. Opt-in and type-only: the untyped forms are unchanged. See the typed payloads guide.
Disposal — dispose() is permanent, irreversible, and cascades to child FSMs by default. All post-dispose calls are silent no-ops.
machina-inspect — Static analysis for FSM configs. Parses configs into a directed graph IR and runs structural checks (unreachable states, _onEnter loops, missing handlers). Use it programmatically or as the engine behind the tools below.
machina-test — Jest/Vitest custom matchers for testing FSM graph topology. Assert reachability, catch dead states, and verify structural invariants from your test suite. Zero ceremony beyond import "machina-test".
eslint-plugin-machina — ESLint plugin that surfaces machina-inspect findings inline in your editor. Three rules, one import to set up.
machina-explorer — Browser-based paste-and-analyze tool. Paste an FSM config, run checks, and render a mermaid state diagram. No install required.
Machina was originally inspired by the gen_fsm behavior module for Erlang/OTP. While TypeScript/JavaScript are a very different landscape than Erlang, machina seeks to preserve some of the same qualities: pragmatic, focused, minimal ceremony, and straightforward to reason about.
Other options exist — most notably XState. XState is a comprehensive statechart framework with actors, spawning, inspection tools, and SCXML compatibility. It's a phenomenal library — if you need those features, use it. Machina is for when you want a state machine (or a hierarchy of them) and nothing else.
machina.js/
packages/
machina/ # core library (npm: "machina")
machina-inspect/ # static analysis for FSM configs
machina-test/ # Jest/Vitest custom matchers for FSM testing
eslint-plugin-machina/ # ESLint plugin wrapping machina-inspect
docs/ # Astro Starlight documentation site
examples/
connectivity/ # network connectivity monitor (createFsm)
traffic-intersection/ # hierarchical FSM with child states
dungeon-critters/ # createBehavioralFsm example
shopping-cart/ # defer() showcase
job-queue/ # BehavioralFsm rehydrate() persist/restore pattern
with-react/ # React integration example
machina-explorer/ # interactive FSM inspector + diagram visualizer
testing-with-machina-test/ # machina-test matcher usage examples
Requires Node >= 22.22 and pnpm 10.29.3.
git clone https://github.com/ifandelse/machina.js.git
cd machina.js
pnpm install
pnpm turbo build
pnpm turbo test
To work on a specific package:
cd packages/machina
pnpm dev # tsdown in watch mode
pnpm test # jest
MIT + GPL-2.0 — see LICENSE
© Jim Cowart
Overall
Maintained
Code review
npm
Latest release
Releases this year
253 followers · starred Feb 2012
3,653 followers · starred May 2013
446 followers · starred Apr 2013
26 followers · starred Feb 2016