A TypeScript state-machine library built around immutable snapshots and typestate.
The core runtime is deliberately small: a machine is an object with a context property and transition methods. A transition returns the next machine snapshot. TypeScript can then represent different states as different types, making invalid transitions unavailable at compile time.
If you already know what you need, jump to the main API or task-oriented documentation map. For the design behind the API, continue with the core tenets below.
npm install @doeixd/machineNode 18 or newer is supported. React and Solid are optional peer dependencies used only by their framework entry points.
| Topic | Guide |
|---|---|
| Public API and supported behavior | Supported API |
| Mathematical model and guarantee boundaries | State machines from first principles |
Factories, classes, and this behavior |
Factories, transition binding |
| Async work and ownership | Cancellation, actors |
| Multi-machine coordination | Ensembles |
| Class-based external-store façade | StoreMachine |
| Nested and conditional behavior | Composition guide, guards |
| Transition policies | Middleware |
| Pattern matching | Advanced matcher, matcher API |
| Composition | Nested and conditional machines, delegation, higher-order machines, mixins |
| Package entry points | Published modules |
| Diagrams and formal output | Statechart extraction |
The complete documentation map also separates supported guides from design notes and historical essays.
The central idea of this library is deliberately plain: a machine is a state snapshot with typed functions that return the next snapshot. The API follows the mathematical model instead of hiding it behind a configuration language.
For a classic finite-state machine (S, Σ, δ, s₀, F):
| Formal part | Representation in this library |
|---|---|
S — control states |
A finite union of typestates, tagged objects, or state classes. Each variant exposes only the transitions valid from that state. |
Σ — inputs |
Transition method names plus their typed arguments: login(username), resolve(data), and so on. Event objects are available when a runner or actor needs message dispatch. |
δ — transition function |
The transition methods themselves. Given the current snapshot and input, each returns the next snapshot. In typestate models this is intentionally a partial function: an invalid transition is absent rather than represented by a failing string lookup. |
s₀ — initial state |
The initial snapshot passed to createMachine() or a runner. |
F — final states |
Optional terminal typestates with no outgoing transitions. The library does not require every machine to have a final state. |
Real applications usually need an extended state machine: a small, finite set of control states plus data such as a username, count, or response. That data lives under context. Its values need not be finite; the typestate union remains the finite control model.
This mapping drives the design:
- State is explicit. A returned machine is a complete snapshot, so history is not ambient hidden state.
- Valid transitions are structural. If a loading state cannot
loadagain, its TypeScript type simply has noloadmethod. - Inputs are ordinary function arguments. Refactoring, parameter checking, and autocomplete use normal TypeScript rather than parallel string schemas.
- The core stays executable. Guards can be conditions, actions can be function calls, and transitions can use the rest of the language.
- Analysis is opt-in metadata. When diagrams or formal statecharts matter, annotations describe the same executable transition instead of creating a second machine definition.
There are honest limits to these guarantees. TypeScript checks the model at compile time, but JavaScript can still mutate nested data, read global state, perform effects, or return nondeterministic results. Immutability, determinism, and the Markov property—depending only on the current snapshot and input—are design rules supported by the API, not runtime laws enforced by it.
Read State machines from first principles for the formal model and the exact boundary of these guarantees.
Main-API machines store data under context.
import { createMachine } from '@doeixd/machine';
const counter = createMachine({ count: 0 }, (next) => ({
increment() {
return next({ count: this.context.count + 1 });
},
add(amount: number) {
return next({ count: this.context.count + amount });
},
}));
const updated = counter.increment().add(4);
console.log(counter.context.count); // 0
console.log(updated.context.count); // 5The factory form is recommended because it preserves the transition set without manually passing it to every new snapshot. The traditional transition-object overload remains supported.
See the supported API, factory guide, and this binding guide for the available construction forms and composition rules.
import { createMachine, type TypeState } from '@doeixd/machine';
type LoggedOut = TypeState<{ status: 'loggedOut' }, {
login(username: string): LoggedIn;
}>;
type LoggedIn = TypeState<{ status: 'loggedIn'; username: string }, {
logout(): LoggedOut;
}>;Each state type lists only its valid transitions. The compiler, rather than a runtime string table, rejects calls to transitions that do not exist on the current state.
runMachine owns the current async snapshot and dispatches typed events. Starting another transition aborts the previous in-flight transition.
import {
createAsyncMachine,
runMachine,
type TransitionOptions,
} from '@doeixd/machine';
const machine = createAsyncMachine({ value: 0 }, {
async load(amount: number, { signal }: TransitionOptions) {
const response = await fetch(`/value?amount=${amount}`, { signal });
const value = await response.json() as number;
return createAsyncMachine({ value }, this);
},
});
const runner = runMachine(machine);
await runner.dispatch({ type: 'load', args: [5] });
console.log(runner.state.value);
runner.stop();runner.state is the current context, not the entire machine. The dispatch result is the entire next machine.
See Async cancellation for dispatch ordering, AbortSignal, stale results, and shutdown behavior.
Actors add queued async dispatch, subscriptions, and stable RPC-style actions.
import { createActor } from '@doeixd/machine';
const actor = createActor(counter);
const unsubscribe = actor.subscribe((state) => {
console.log(state.context.count);
});
actor.send.increment();
unsubscribe();See Actors for mailbox ordering, lifecycle, inspection, and promise or observable adapters.
createMatcher turns a machine union into three complementary APIs: a callable state classifier, reusable type guards, and exhaustive case handling.
import {
MachineBase,
classCase,
createMatcher,
} from '@doeixd/machine';
class Idle extends MachineBase<{ status: 'idle' }> {}
class Loading extends MachineBase<{ status: 'loading'; startedAt: number }> {}
class Success extends MachineBase<{ status: 'success'; data: string }> {}
class Failed extends MachineBase<{ status: 'error'; error: Error }> {}
type FetchMachine = Idle | Loading | Success | Failed;
const matchFetch = createMatcher(
classCase('idle', Idle),
classCase('loading', Loading),
classCase('success', Success),
classCase('error', Failed),
);
function describeFetch(snapshot: FetchMachine): string {
matchFetch(snapshot); // 'idle' | 'loading' | 'success' | 'error' | null
if (matchFetch.is.success(snapshot)) {
console.log(snapshot.context.data); // narrowed to Success
}
return matchFetch.when(snapshot).is(
matchFetch.case.idle(() => 'Ready'),
matchFetch.case.loading(state => `Started at ${state.context.startedAt}`),
matchFetch.case.success(state => `Loaded: ${state.context.data}`),
matchFetch.case.error(state => `Failed: ${state.context.error.message}`),
matchFetch.exhaustive,
);
}Omitting a case makes the inferred result an error type instead of string. For discriminated contexts, forContext<ContextUnion>() provides concise case builders; customCase accepts arbitrary type-guard predicates. See the matcher API and matcher module.
The main entry also exports:
- transition middleware, validation, retry, history, snapshots, and time travel;
createRunnerfor a stable imperative façade over immutable snapshots;createEnsemblefor coordinating multiple machine domains through shared external state;createStoreMachinefor class methods over read-only live fields from an external store;- pattern matching for class states and discriminated contexts;
- class and functional mixins;
- generator-based transition composition.
These are optional layers. They are not required to use the core machine factories.
Continue with Middleware, conditional transitions, or the higher-order machine guide.
import { createMachine, useMachine } from '@doeixd/machine/react';
const initial = createMachine({ count: 0 }, (next) => ({
increment() {
return next({ count: this.context.count + 1 });
},
}));
function Counter() {
const [machine, actions] = useMachine(() => initial);
return <button onClick={actions.increment}>{machine.context.count}</button>;
}See the React API notes for selectors, contexts, ensembles, and actor hooks.
The Solid entry exports reactive helpers. Import non-reactive factories from the package root.
import { createMachine as createCoreMachine } from '@doeixd/machine';
import { createMachine } from '@doeixd/machine/solid';
const initial = () => createCoreMachine({ count: 0 }, (next) => ({
increment() {
return next({ count: this.context.count + 1 });
},
}));
function Counter() {
const [machine, actions] = createMachine(initial);
return <button onClick={actions.increment}>{machine().context.count}</button>;
}Solid action objects are stable and resolve transitions against the current snapshot. Calling an action that is unavailable in the current typestate throws a descriptive runtime error.
See the Solid API notes for stores, selectors, batching, contexts, async ownership, and effects.
The optional @doeixd/machine/minimal entry uses flat snapshots: state and transitions live on the same object.
import { machine } from '@doeixd/machine/minimal';
const counter = machine({ count: 0 }, (state, next) => ({
increment: () => next({ count: state.count + 1 }),
add: (amount: number) => next({ count: state.count + amount }),
}));
const updated = counter.increment().add(4);
console.log(counter.count); // 0
console.log(updated.count); // 5Use factory() when the same machine should be instantiated with different initial data. Use union() with tagged states when each state has a different set of transitions.
import { tag, type States, union, type UnionOf } from '@doeixd/machine/minimal';
type FetchState = States<{
idle: {};
loading: { url: string };
success: { data: string };
}>;
const State = tag.enum<FetchState>()(
tag('idle'),
tag('loading'),
tag('success'),
);
const createFetch = union<FetchState>()({
idle: (_state, next) => ({
load: (url: string) => next(State.loading({ url })),
}),
loading: (_state, next) => ({
resolve: (data: string) => next(State.success({ data })),
cancel: () => next(State.idle()),
}),
success: (_state, next) => ({
reset: () => next(State.idle()),
}),
});
type FetchMachine = UnionOf<typeof createFetch>;
const first: FetchMachine = createFetch(State.idle());
const second = first.load('/api/data');
// second.load(...) is a type error: loading states do not have `load`.
// State.loading() is a type error: `url` is required by FetchState.tag.enum(tag('one'), tag('two')) is also available when each factory should
infer an open-ended payload at the call site. The generic, curried form shown
above constrains every payload and definition name to an existing States<...>
member. See Tagged helpers for both modes.
The main and minimal APIs intentionally use different snapshot shapes; their examples are not interchangeable without adjusting property access. See docs/minimal.md for factories, tagged unions, exhaustive matching, lifecycle hooks, child composition, and the exact boundary of this smaller runtime.
Every entry below is built as ESM and CommonJS and includes TypeScript declarations. npm run build fails if any configured export target is missing.
| Subpath | Purpose |
|---|---|
@doeixd/machine |
Complete main API |
@doeixd/machine/core |
Main API without React or Solid helpers |
@doeixd/machine/minimal |
Flat typestate API |
@doeixd/machine/actor |
Actors, spawning, promises, and observables |
@doeixd/machine/adapters |
EventTarget, EventEmitter, and Observable adapters |
@doeixd/machine/base |
MachineBase only |
@doeixd/machine/context-bound |
Context-bound transition helpers |
@doeixd/machine/delegate |
Child-transition delegation |
@doeixd/machine/devtools |
Browser DevTools connection helper |
@doeixd/machine/extract |
Programmatic static statechart extraction |
@doeixd/machine/functional-combinators |
Functional construction and transition combinators |
@doeixd/machine/generators |
Generator-based transition sequences |
@doeixd/machine/higher-order |
Higher-order machine transformations |
@doeixd/machine/matcher |
Reusable typed pattern matching |
@doeixd/machine/middleware |
Middleware, composition, history, snapshots, and time travel |
@doeixd/machine/mixins |
Class and functional machine composition |
@doeixd/machine/multi |
Runners, ensembles, and multi-machine coordination |
@doeixd/machine/primitives |
Guards and metadata annotations |
@doeixd/machine/runtime-extract |
Statechart metadata extraction from live instances |
@doeixd/machine/types |
Tagged-state runtime and type helpers |
@doeixd/machine/utils |
Events, context helpers, binding, and sequences |
@doeixd/machine/react |
React integration |
@doeixd/machine/solid |
Solid integration |
Focused subpaths are useful when you want an explicit dependency boundary or a smaller direct bundle. Files such as internal-transitions.ts, entry-react.ts, and entry-solid.ts are implementation details and are deliberately not public subpaths.
See the published module guide for the main exports, intended use, and caveats of each entry point.
Metadata helpers annotate transition functions for runtime or build-time extraction. The metadata decorators (transitionTo, describe, guarded, invoke, action, and metadata) support direct and curried forms, and pipe applies the curried operators from left to right. Runtime-enforcing guards retain their direct and fluent APIs.
import { action, describe, pipe, transitionTo } from '@doeixd/machine';
class Loading {}
class Idle {
start = pipe(
() => new Loading(),
transitionTo(Loading),
describe('Start loading'),
action({ name: 'recordStart' }),
);
}The equivalent nested form remains supported:
const start = describe(
'Start loading',
transitionTo(Loading, () => new Loading()),
);Metadata usually belongs to a transition—the edge between snapshots—so the recommended API is the standalone pipe(value, ...operators), not a .pipe() method added to every machine. Transition decorators keep the original call signature, while annotations are attached as non-enumerable runtime metadata and represented in the TypeScript type. The generic metadata({...}) operator can annotate any object or function, including a complete machine snapshot, without changing its identity. Later pipe operators are the outer decorators; for repeated actions or guards, their metadata therefore appears first.
Run the extractor in this repository with:
npm run extractFrom an installed package:
npx --package @doeixd/machine extract --config .statechart.config.ts --validateThe CLI supports JSON, Mermaid, or both, and validates generated JSON against the bundled schema when requested. See docs/statechart-extraction.md.
- Transition helpers return new snapshots by convention and by their types.
contextis typed as readonly at the machine-property level.- Context objects are not recursively frozen at runtime.
- Methods can still perform side effects; purity is a design rule, not something JavaScript can enforce.
- Runtime controllers validate missing transitions, but most transition validity comes from TypeScript.
Avoid describing the library as “zero runtime” or claiming that immutability and purity are enforced. The core is small, but actors, middleware, extraction, and framework adapters intentionally add runtime behavior.
See Mutability boundaries for practical guidance on immutable snapshots, nested data, and integration points that require mutation.
npm install
npm test
npm run type-check
npm run build
npm run verify:package
npm run extractnpm run build cleans old output, produces ESM/CommonJS/declaration bundles for every configured entrypoint, and verifies the package export map. npm run verify:package can also be run independently after a build.
See docs/README.md for the documentation map and docs/api.md for the supported public surface.
MIT