Skip to content

State Machines

Write state machines with @typeonce/effect-machine — the schema-first Machine API proposed in Effect PR #6429. The effect-analyzer package (which provides the effect-analyze CLI) reads those machines statically, renders statechart diagrams, exports an XState config for stately.ai/viz, and checks the machine for structural completeness.

Nothing is executed. The analyzer reads the state tree and the handler tree and derives the model: states, events, transitions, hierarchy, parallel regions, invoked children, and entry/exit actions.

import { Machine } from '@typeonce/effect-machine'
import { Schema } from 'effect'
class Closed extends Schema.TaggedClass<Closed>('Closed')('Closed', {}) {}
class Open extends Schema.TaggedClass<Open>('Open')('Open', {}) {}
class Toggle extends Schema.TaggedClass<Toggle>('Toggle')('Toggle', {}) {}
const DoorStates = Machine.defineStates({ Closed, Open })
export const DoorMachine = Machine.make({
states: DoorStates.states,
events: [Toggle],
initial: () => DoorStates.initial.Closed(new Closed()),
}).handle({
Closed: { on: { Toggle: ({ target }) => target.full.Open(new Open()) } },
Open: { on: { Toggle: ({ target }) => target.full.Closed(new Closed()) } },
})

Machine-only files have no Effect programs, so use a statechart format. These skip the Effect IR path and extract machines directly:

Terminal window
npx effect-analyze ./door.ts --format mermaid-statechart

For the full visualizer, use statechart-html. With no -o it writes door.statechart.html next to the input. Add --open to launch it:

Terminal window
npx effect-analyze ./door.ts --format statechart-html --open

That page includes the statechart, a coverage report, and an XState config you can paste into stately.ai/viz.

When a file also contains Effect programs, the default view (no --format) still runs Effect analysis first, then appends a mermaid statechart for every detected machine plus a hint pointing at statechart-html.

The recognized shape is Machine.make({...}).handle({...}). The machine’s name is the variable it is assigned to.

The states: value — either an inline literal or the .states of a Machine.defineStates({...}) declared in the same file. A leaf is a tagged schema; a node is { schema, type?, initial?, states }:

export const EditorStates = Machine.defineStates({
workspace: {
schema: Workspace,
type: 'parallel',
states: {
document: { schema: Document, initial: 'Clean', states: { Clean, Dirty } },
connection: { schema: Connection, initial: 'Online', states: { Online, Offline } },
},
},
})

Nesting becomes dotted paths (workspace.document.Clean) that nest in the mermaid diagram and the exported XState config (absolute #id.path targets so cross-level transitions resolve). A compound node’s declared initial becomes an initial edge; a type: 'parallel' node gets one to every region, so all regions count as reachable. A state marked type: 'final' in either the state tree or its .handle() configuration renders as final. A leaf state with no outgoing handler is drawn as an ordinary state, never guessed to be terminal.

If the state tree lives in another file, the machine is reported as a near-miss rather than drawn — the analyzer never leaves the file it is given.

.handle({...}) mirrors the state tree. Per state node the analyzer reads:

Key Becomes
on: { Event: handler } one transition per resolved target
on: { Event: { reenter, transition } } same, reading transition
always an eventless transition (always in the config)
onDone a completion transition
entry / exit an action label on the state
invoke invoke: { src, id } on the state
type: 'final' an explicitly final state
states recursion into the children

A handler’s target is read from the target builders, wherever they appear in the body — inside a .pipe, a ternary, or an Option.match. Every distinct target found becomes a transition, so a branching handler draws every branch.

target.full.Paying(new Paying({ amount: event.amount })) // absolute path
target.local.Dirty(new Dirty()) // sibling in the nearest compound
target.local.with(new Document({ title }), (d) => d.Dirty(new Dirty())) // re-enter, then descend

Nested region builders are followed (target.full.payment(v, (p) => p.entering(v)) resolves to payment.entering). When a builder enters several regions at once, the parent path is used — the sound target for a parallel entry.

Worked example: the upstream Pokémon statechart

Section titled “Worked example: the upstream Pokémon statechart”

The Pokémon example that ships with @typeonce/effect-machine exercises most of the surface at once — a parallel root, two compound regions, an invoked search, a child machine per region, and target.local.with(...). Pointed straight at it:

Terminal window
npx effect-analyze ./examples/pokemon/src/machines/selection.ts --format xstate-config
export const SelectionMachineMachine = createMachine({
id: 'SelectionMachine',
initial: 'form',
states: {
form: {
type: 'parallel',
states: {
search: {
on: { UpdateSearchText: '#SelectionMachine.form.search.Searching' },
initial: 'NoPokemon',
states: {
NoPokemon: {},
WithPokemon: { on: { ReplacePokemon: '#SelectionMachine.form' } },
Searching: {
invoke: { src: 'search', id: 'search' },
on: { SearchResult: [{ target: '#SelectionMachine.form.search.NoPokemon' }, { target: '#SelectionMachine.form.search.WithPokemon' }] }
}
}
},
selection: {
initial: 'Unselected',
states: {
Unselected: { on: { SelectPokemon: '#SelectionMachine.form.selection.Selected' } },
Selected: { on: { SelectPokemon: [{ target: '#SelectionMachine.form.selection.Unselected', guard: 'state.id === event.id' }, { target: '#SelectionMachine.form.selection.Selected', guard: '!(state.id === event.id)' }] } }
}
}
}
}
}
});

Three things to note, because they are the general rules:

  • ReplacePokemon targets form, not a leaf. Its handler enters both regions at once, so the parallel parent is the sound target — re-entering form restores each region’s declared initial, which is what the handler does.
  • SearchResult yields two unguarded targets. The handler branches with Option.match, not an if/ternary, so there is no condition text to read. Guards only appear where the source has a real branch condition.
  • The invoked child is labelled with its declared id (search), followed through the local factory that builds it. Machine.child('selection', M) is read the same way.

Machines authored anywhere can be ingested as data in the MachineJSON shape, XState v6’s machine-as-data format (what serializeMachine emits and createMachineFromConfig accepts). It is a plain object literal: no XState dependency, and effect-analyzer ships its own structural MachineJSON type.

The analyzer ingests nested states, parent-level on handlers, parallel regions, always / after, guards, entry / exit and transition actions, invoke with onDone / onError, and explicit type: 'final' states. Be precise about what that means: these are rendered, exported, and checked over the flattened transition graph — coverage does not model event bubbling (a parent’s handler doesn’t count as the child handling the event), and history nodes pass through as ordinary states with no restore semantics.

This is a programmatic API — the CLI does not discover MachineJSON in source files. Pass the object to fromMachineJSON and use the result with the same coverage engine and renderers:

import {
fromMachineJSON,
computeStateMachineCoverage,
renderStatechartMermaid,
renderXStateConfig,
type MachineJSON,
} from 'effect-analyzer/analysis'
const player = {
id: 'player', // optional; defaults to 'machine'
initial: 'Playing',
states: {
Playing: {
initial: 'Running',
on: { Stop: 'Stopped' },
states: {
Running: { on: { Pause: 'Paused' } },
Paused: { on: { Play: 'Running' } },
},
},
Stopped: { type: 'final' },
},
} satisfies MachineJSON
const machine = fromMachineJSON(player)
const coverage = computeStateMachineCoverage(machine)
const diagram = renderStatechartMermaid(machine, coverage)
const config = renderXStateConfig(machine)
Terminal window
npx effect-analyze ./workflow.ts --format statechart-html -o statechart.html
npx effect-analyze ./workflow.ts --format xstate-config
npx effect-analyze ./workflow.ts --format mermaid-statechart
npx effect-analyze ./workflow.ts --format svg-statechart
npx effect-analyze ./workflow.ts --format statechart-coverage

statechart-html is the local visualizer: SVG diagram, coverage report, and paste-ready XState config in one page. xstate-config is for stately.ai/viz import.

The state tree and the events: list are the declared alphabet. The analyzer compares extracted transitions against it and reports:

  • Unhandled declared events — declared events that no state handles (warning). Incomplete (state, event) cells lower the coverage %, but only machine-wide unused events raise this warning.
  • Unreachable states (warning)
  • Undeclared states or events used by transitions — drift from the tree (warning)
  • Dead-end states — no outgoing transition of any kind, treated as final (info; mark intentional ones type: 'final')

What the coverage number measures. Coverage is the percentage of handled (state, event) pairs over the pairs the machine is expected to handle:

coverage = handled pairs / (event-handling reachable states × declared events)

States with no event-driven exits (a final state, or a state whose only exits are automatic) are excluded from the denominator, and automatic transitions are never counted as events. A spread event list (events: [...Child.emits]) can’t be resolved statically, so that machine degrades to reachability-only checks.

For CI:

Terminal window
npx effect-analyze ./src --format statechart-coverage
npx effect-analyze ./src --format statechart-coverage --min-coverage 60
npx effect-analyze ./src --format statechart-coverage --coverage-json

The command exits non-zero when any warning-level finding exists or a machine falls below --min-coverage. Info findings — including a perfectly valid terminal state — never fail the gate.

Statechart formats (mermaid-statechart, svg-statechart, statechart-html, xstate-config, statechart-coverage) print a header naming each machine found and its state and event counts. When none are found, they list declarations that came close and why — a Machine.make whose state tree is imported, or a Machine.defineStates no machine in the file consumes — including the file, line number, and suggested fix.

The analyzer models, renders, and checks; it does not execute. Action and invoke labels are names, not behaviour — @typeonce/effect-machine runs the machine, effect-analyzer draws it. Coverage works over the flattened transition graph, so event bubbling from a parent handler down to a child is not modeled.