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.
Quickstart
Section titled “Quickstart”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:
npx effect-analyze ./door.ts --format mermaid-statechartFor the full visualizer, use statechart-html. With no -o it writes
door.statechart.html next to the input. Add --open to launch it:
npx effect-analyze ./door.ts --format statechart-html --openThat 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.
What Is Read
Section titled “What Is Read”The recognized shape is Machine.make({...}).handle({...}). The machine’s name
is the variable it is assigned to.
State tree
Section titled “State tree”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.
Handler tree
Section titled “Handler tree”.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 |
Targets
Section titled “Targets”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 pathtarget.local.Dirty(new Dirty()) // sibling in the nearest compoundtarget.local.with(new Document({ title }), (d) => d.Dirty(new Dirty())) // re-enter, then descendNested 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:
npx effect-analyze ./examples/pokemon/src/machines/selection.ts --format xstate-configexport 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:
ReplacePokemontargetsform, not a leaf. Its handler enters both regions at once, so the parallel parent is the sound target — re-enteringformrestores each region’s declared initial, which is what the handler does.SearchResultyields two unguarded targets. The handler branches withOption.match, not anif/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.
MachineJSON
Section titled “MachineJSON”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)Tooling Outputs
Section titled “Tooling Outputs”npx effect-analyze ./workflow.ts --format statechart-html -o statechart.htmlnpx effect-analyze ./workflow.ts --format xstate-confignpx effect-analyze ./workflow.ts --format mermaid-statechartnpx effect-analyze ./workflow.ts --format svg-statechartnpx effect-analyze ./workflow.ts --format statechart-coveragestatechart-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.
Coverage Gate
Section titled “Coverage Gate”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:
npx effect-analyze ./src --format statechart-coveragenpx effect-analyze ./src --format statechart-coverage --min-coverage 60npx effect-analyze ./src --format statechart-coverage --coverage-jsonThe 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.
Detection Diagnostics
Section titled “Detection Diagnostics”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.
Non-Goals
Section titled “Non-Goals”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.