Schema-first state machines and statecharts for Effect.
Early-release software: APIs may change, and releases are coupled to an exact Effect beta.
pnpm add @typeonce/effect-machine effect@4.0.0-beta.102effect is an exact peer dependency, not a bundled runtime dependency.
Consumers must install effect@4.0.0-beta.102. Upgrading this package may
require upgrading Effect in lockstep; do not override the peer to another beta.
import { Machine } from "@typeonce/effect-machine"
import { ClusterMachine } from "@typeonce/effect-machine/cluster"
import { AtomMachine } from "@typeonce/effect-machine/reactivity"Each ESM entrypoint is independent and tree-shakeable. Importing the root does not load the reactivity or cluster adapters.
Schemas provide runtime decoders and the types used by handlers, targets, inputs, outputs, and running references. The public/internal event distinction has an additional boundary described below.
Effect's Schema.TaggedUnion is a compact way to declare cases. Its cases
property contains the individual tagged schemas, and each case has a typed
make constructor.
import { Machine } from "@typeonce/effect-machine"
import { Schema } from "effect"
const State = Schema.TaggedUnion({
Idle: {},
Running: {}
})
const Event = Schema.TaggedUnion({
Start: {}
})
const States = Machine.defineStates(State.cases)
const Counter = Machine.make({
id: "Counter",
states: States.states,
events: [Event.cases.Start],
initial: () => States.initial.Idle.from()
}).handle({
Idle: {
on: {
Start: ({ target }) => target.full.Running.from()
}
},
Running: {}
})Handler objects mirror the state definition recursively. effect-machine does
not impose a fixed handler-tree depth; inference continues until TypeScript's
normal, shape-dependent compiler limits.
initial is always a function. For a machine with an input schema, the
initializer receives the decoded input.
Builder methods accept an already constructed state value directly, or expose
.from for constructing one safely from the state schema's make input:
target.local.Running(decodedRunning)
target.local.Running.from({ startedAt: event.at })Use the direct call when a decoded value already exists. Use .from when
entering a state from fields. Construction runs through the schema's
makeEffect while the machine plans the configuration, so constructor
defaults and tagged-class identity are preserved and failed refinements become
MachineSchemaDecodeError failures instead of synchronous throws. The same
form is available on initial, local, branch, full, compound, parallel, and
final builders. A .from builder result is therefore a machine construction
instruction; it becomes a validated public snapshot when planning succeeds.
When {} is valid constructor input, omit it. Required state fields remain
required, while compound and parallel states still require their active-child
callback:
States.initial.Idle.from()
States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())
States.initial.Flow.from((flow) => flow.Idle.from())Tagged classes are equally valid when cases need class methods or nominal identity:
class Idle extends Schema.TaggedClass<Idle>("Idle")("Idle", {}) {}Declare commands that callers may send in events. Declare machine-local
deliveries, such as invoke results and child emissions, in internalEvents:
const Command = Schema.TaggedUnion({
Save: {}
})
const InternalEvent = Schema.TaggedUnion({
Saved: { id: Schema.String },
SaveFailed: { message: Schema.String }
})
const machine = Machine.make({
states: States.states,
events: [Command.cases.Save],
internalEvents: [InternalEvent.cases.Saved, InternalEvent.cases.SaveFailed],
initial: () => States.initial.Idle.from()
})Handlers and machine logic see the complete union. Local public APIs such as
MachineRef.send, machineAtom.send, and Machine.plan expose only events
in TypeScript. The local planner and runtime still share the complete event
decoder so machine-local deliveries can flow through the same execution
protocol; bypassing the types with JavaScript or any is therefore not a
runtime authorization boundary. Cluster RPC delivery additionally validates
incoming payloads against the public events schemas.
The utility types make the distinction available to application code:
type PublicCommand = Machine.Machine.InputEvent<typeof machine>
type HandledEvent = Machine.Machine.Event<typeof machine>Tags must be unique within each list, and public and internal tags must be disjoint. Reusing a tag is a type error, so a command cannot accidentally masquerade as an internal result.
Machine.defineStates accepts atomic, compound, parallel, final, choice, and history
nodes:
const State = Schema.TaggedUnion({
Form: { draft: Schema.String },
Editing: {},
Saving: {},
Done: {}
})
const States = Machine.defineStates({
Form: {
schema: State.cases.Form,
initial: "Editing",
states: {
Editing: State.cases.Editing,
Saving: State.cases.Saving,
Done: {
schema: State.cases.Done,
type: "final",
output: Schema.String
}
}
}
})Compound states have one active child and declare its initial key. Parallel
states use type: "parallel" and have one active state in every direct region.
Finality is topology, so declare type: "final" only in the state definition.
Handlers implement behavior and output computation without repeating it:
const machine = Machine.make({
states: States.states,
events: [],
initial: () => States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())
}).handle({
Form: {
states: {
Done: {
output: () => "saved"
}
}
}
})Every declared output schema must have a matching handler implementation before
the machine can be planned, started, invoked, or adapted to Atom/Cluster.
Final children complete their parent; put onDone on that compound or parallel
parent, not on the final leaf.
Put data on the narrowest state where it is valid. If several sibling phases share data, prefer storing it on their compound parent instead of copying it into every child state.
A choice is a transient, targetable decision point. Declare it with only
type: "choice"; it has no schema, value, children, lifecycle actions, invoke,
or event handlers. A choice is therefore absent from StateIdentifier, stable
snapshots, configurations, and encoded snapshots.
const States = Machine.defineStates({
Flow: {
schema: State.cases.Flow,
initial: "Routing",
states: {
Routing: { type: "choice" },
Approved: State.cases.Approved,
Rejected: State.cases.Rejected
}
}
})
const machine = Machine.make({
states: States.states,
events: [Event],
initial: () =>
States.initial.Flow(
State.cases.Flow.make({ score: 80 }),
(flow) => flow.Routing()
)
}).handle({
Flow: {
states: {
Routing: {
choice: {
targets: ["Flow.Approved", "Flow.Rejected"],
transition: ({ parent, target }) =>
parent.score >= 70
? target.local.Approved(State.cases.Approved.make({}))
: target.local.Rejected(State.cases.Rejected.make({}))
}
}
}
}
})The resolver is ordinary TypeScript or an Effect. It receives the triggering
lifecycle event, typed parent values, target builders, and the normal planning
capabilities, but no state because the choice itself has no state value. Its
declared targets are both a compile-time bound and inspectable graph edges.
The resolver must return one of them; missing, malformed, or undeclared targets
fail planning. Choice implementations are required before execution APIs are
available.
Initial, event, completion, always, history-default, and other choice
transitions settle in the same macrostep. Chained choices use the normal
infinite-transition limit. Choice nodes never run entry or exit actions and
never become active while their resolution remains visible in traces and
coverage as a choice transition trigger.
A history pseudo-state remembers the last active configuration of its parent.
It has no value schema and never appears in an active snapshot. History is
shallow by default; use history: "deep" to retain the complete descendant
configuration and its validated values:
const States = Machine.defineStates({
checkout: {
schema: Checkout,
initial: "shipping",
states: {
shipping: Shipping,
payment: {
schema: Payment,
initial: "cardEntry",
states: {
cardEntry: CardEntry,
verifying: Verifying
}
},
resume: { type: "history", history: "deep" }
}
},
support: Support
})Implement a typed default for the first transition before any configuration has been remembered, then target history without supplying a state value:
machine.handle({
checkout: {
history: {
resume: {
default: () => initialCheckoutSnapshot
}
}
},
support: {
on: {
Resume: ({ target }) => target.history.checkout.resume()
}
}
})A history default is source-independent. It must construct a complete root configuration containing the history owner, including every inactive ancestor above a nested owner and every required region of a parallel ancestor. For a top-level owner, its owner snapshot is already a complete root snapshot.
For example, a history node owned by App.Workspace can be targeted from an
unrelated Closed root and supplies the complete App configuration on first
use:
Workspace: {
history: {
resume: {
default: ({ target }) =>
target.App(
State.cases.App.make({ workspaceId: "default" }),
(app) =>
app.Workspace(
State.cases.Workspace.make({}),
(workspace) =>
workspace.Editing(State.cases.Editing.make({}))
)
)
}
}
}The containing branch is enforced statically: unrelated roots, sibling compound branches that omit the owner, owner-only nested snapshots, and incomplete parallel configurations are rejected.
Deep history restores every remembered descendant value. Shallow history
restores the parent and direct-child values, then follows normal initial paths.
Only compound or parallel states that shallow restoration can enter implicitly
need an initial handler to construct those new child values:
payment: {
initial: ;
;(({ state }) => new CardEntry({ attempt: state.attempt, cardNumber: "" }))
}Execution APIs remain unavailable until required history defaults and shallow
initializers have been implemented. History records are part of logical
snapshots and are schema-validated by encodeSnapshot and decodeSnapshot.
Transition between structurally related tagged states with Machine.retag.
The source _tag is discarded, compatible fields are reused, and missing or
incompatible required fields must be supplied:
const saving = Machine.retag(State.cases.Saving, editing)Transition contexts expose four typed target builders:
| Builder | Destination | Configuration behavior |
|---|---|---|
target.local |
Inside the source's nearest compound scope | Keeps the compound value, active ancestors, and unrelated parallel regions |
target.branch |
Anywhere under the source's active top-level root | Replaces the selected branch while keeping omitted active ancestor values and parallel regions |
target.full |
Any top-level root | Builds a complete active snapshot for the selected root |
target.history |
A declared history pseudo-state | Restores its parent's remembered configuration or runs its typed default |
When target.local or target.branch enters an inactive nested parallel
state, its callback must select every region, just like initial and
target.full. When that parallel state is already active, target.branch
can still update one region directly and preserves the other active regions.
The builder controls how the next configuration is assembled; it does not by
itself decide which invokes restart. The runtime derives exit and entry paths
from the previous and next active paths. Shared active ancestors remain entered,
even when target.full supplies their values again. Use an event transition
with reenter: true when the source state should explicitly exit and enter
again:
Refresh: {
reenter: true,
transition: ({ state, target }) =>
target.full.Ready(new Ready({ value: state.value }))
}States.get, States.getWithParents, States.getSnapshot, and
States.matches accept typed dotted paths. Handler parents values are also
keyed by full dotted paths, such as parents["Form.Editing"]; context.parent
provides the immediate parent directly and is undefined at a root state.
Event, eventless, and completion transition contexts also expose snapshot, a
read-only view of the complete logical configuration captured at the beginning
of that transition microstep. This lets one parallel region inspect a sibling
without copying active-state facts into parent values:
BufferReady: ;
;(({ snapshot, target }) =>
States.matches(snapshot, "Player.Network.Online")
? target.local.Playing(State.cases.Playing.make({}))
: undefined)All non-conflicting handlers selected together observe the same captured
snapshot. An Effectful handler keeps that value; it does not read mutable live
runtime state later. snapshot is intentionally absent from entry, exit,
invoke, and choice contexts. In particular, startup and chained choices may run
before a complete stable snapshot containing their pseudo-source exists.
Effect Schema annotations are the metadata source for active states. Annotate
the schema itself; Machine.stateNodes exposes the resolved annotation map:
const Saving = State.cases.Saving.annotate({
title: "Saving document",
description: "Persisting local changes to the server"
})Schema-less choice and history nodes accept only descriptive title,
description, and documentation annotations. Titles may be used as display
labels, but structural paths remain the only identity and targeting mechanism.
An Effect returned by a transition handler is part of planning. Use it to read
services, choose a target, raise an event, or emit an event. Wrap external side
effects in Machine.action; actions are staged during planning and run by the
managed runtime before it publishes the next state.
const handlers = {
Save: ({ target }) => Machine.action(writeAuditLog, target.local.Saving.from())
}The one-argument form returns void after staging. The two-argument form
returns its second argument, which avoids a generator when an action and the
next target are the whole transition.
If an action fails, the runtime keeps the previously published state and suppresses emissions from that plan.
Machine.plan and Machine.planInitial return a done discriminator. When
done is true, output is the schema-derived structural terminal union;
while the machine remains active, it is undefined. A started machine's
join uses the same terminal union. Output-less structural terminal paths
contribute undefined, while active atomic roots do not.
This union is intentionally conservative with respect to handler behavior. For
example, a root onDone transition may make one structurally terminal result
unreachable even though its schema remains in Machine.TerminalOutput.
Machine.invoke runs child logic while its owning state is active. Leaving the
state interrupts the child. For a one-shot Effect, Machine.invokeEffect maps
typed success and failure values directly to internal events:
const loading = {
invoke: ({ state }) =>
Machine.invokeEffect({
id: "save",
effect: save(state),
onSuccess: (entry) => InternalEvent.cases.Saved.make({ id: entry.id }),
onFailure: (error) =>
InternalEvent.cases.SaveFailed.make({
message: String(error)
})
})
}Omit onFailure when the Effect cannot fail. Defects and interruption remain
failures rather than being mapped.
Machine.after creates a cancellable, state-scoped delayed event with the same
lifetime:
invoke: Machine.after("3 seconds", InternalEvent.cases.SaveFailed.make({ message: "Timed out" }), {
id: "save-timeout"
})Provide an explicit id when more than one active timer could deliver the same event tag.
Use lower-level Machine.invoke with Machine.effect for custom child logic or
snapshot mapping. Its id is only the state-local lifecycle key. If the parent
must send events to that invocation, create a typed low-level address with
Machine.childAddress<Event>("worker") and pass it through the explicit
address option; the address protocol is checked against the child logic.
Lifecycle ids must be unique among simultaneously active invokes owned by the
same state.
Invoke outputs, invoke snapshot events, and invoked-child emissions belong in
internalEvents. They are available to typed handlers but are not accepted by
the typed public input APIs. Include a child machine's emitted protocol with
internalEvents: [...ChildMachine.emits] when those emissions should be handled
by the parent.
For a child statechart, create one descriptor for invokeMachine, sendTo,
and child lookup:
const Editor = Machine.child("editor", EditorMachine)Machine.child(id, machine) is the complete statechart descriptor;
Machine.childAddress<Event>(id) is the lower-level event-only address.
Descriptors are matched by id and machine identity, so independently created
descriptors for the same pair address the same child without a global cache.
Exporting one descriptor remains the clearest module boundary.
Machine.activityDefinitions(machine) inspects state-owned work without
executing it. Static descriptors report their source path, lifecycle id, and
kind. Timers also report normalized duration and emitted event tag;
invokeEffect mappings are described as dynamic; invoked machines expose only
safe child identity. A function-valued invoke factory is reported as dynamic
and is never evaluated during inspection. The result is serializable and does
not contain Effects, closures, services, or child runtimes.
AtomMachine.make creates a lazy bridge backed by one running machine per
AtomRegistry. Mounting or reading one of its atoms starts the machine;
disposing the registry-owned reference stops it.
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
import { Atom } from "effect/unstable/reactivity"
const runtime = Atom.runtime(AppLayer)
const machines = AtomMachine.bind(runtime)
const machineAtom = machines.make(Counter)For applications with a shared runtime, treat
AtomMachine.bind(runtime).make(...) as the canonical form. It keeps runtime
ownership at the composition boundary so it does not need to be passed through
every feature. Service-free machines may use AtomMachine.make(machine)
directly.
The bridge exposes:
ref: the runningMachineRefresult: fail-aware logical state, combining startup and post-start runtime failuressnapshot: authoritative runtime lifecycle, includingactive,done,error, andstoppedstate: the last logical state, including the retained state after a runtime failuresendandstop: writable command atomschild(descriptor): a reactive bridge for a directly owned child
Use AtomMachine.select and AtomMachine.matches for equality-aware root
derivations. Use selectChild and matchesChild for child bridges. Selector
paths and selected value types are inferred directly from the bridge snapshot,
so these combinators do not need the DefinedStates object. They follow normal
Atom identity semantics and return a new atom on each call, so retain or memoize
them when constructing them in a component. The child method uses Effect's
Atom.family to reuse a live bridge for the same descriptor without maintaining
a package-level cache.
AtomMachine.ChildMachineAtom<typeof Child> uses unknown as its startup-error
default for general component props.
AtomMachine.ChildOf<typeof parentAtom, typeof Child> preserves the exact
parent startup-error channel.
Child state and snapshot atoms contain Option.none() while that child is
inactive. React applications using @effect/atom-react need a
RegistryProvider; see the Pokémon example.
Machine.encodeSnapshot and Machine.decodeSnapshot validate logical
statechart data for storage or transport. The encoded representation does not
contain the machine definition, machine version, services, subscriptions, or
running child processes. Store machine identity and migration/version metadata
alongside it.
Resume a decoded logical snapshot explicitly:
const encoded = yield * Machine.encodeSnapshot(machine, snapshot)
const decoded = yield * Machine.decodeSnapshot(machine, encoded)
const ref = yield * Machine.resume(machine, decoded)resume does not call initial, require machine input, or replay entry,
transition, completion, eventless, raised-event, or emitted-event work that
produced the snapshot. The decoded snapshot is the first published logical
state. A final snapshot immediately yields a completed ref with its output.
Resumption creates a fresh runtime. Invokes owned by active states start once in
normal ancestor/document order and receive Machine.InitialEvent as their
lifecycle event. invokeEffect runs again, invoked machines start from their
own initial state, and Machine.after timers restart from their full declared
duration. Spawned children, queued events, subscriptions, fibers, scopes,
elapsed timer time, child snapshots, and prior RuntimeSnapshot status/errors
are not restored. Completion and history metadata remain logical state and are
not replayed. A changed machine definition does not cause resume itself to
evaluate newly enabled always or onDone transitions.
Reactive applications use AtomMachine.resume(machine, decoded) for a
service-free machine or AtomMachine.bind(runtime).resume(machine, decoded)
for a service-backed machine. These bridges have the same lazy one-runtime-per-
registry ownership and disposal behavior as AtomMachine.make.
ClusterMachine provides a separate persisted entity adapter. Its process-local
restrictions, checkpoint planning, and delivery guarantees are documented on
that API. Machine.resume is logical resumption, not durable process or cluster
restoration.
Declarative first-class guards are not part of the current API. Ordinary
TypeScript conditions implement guards. Use Machine.after for a cancellable
state-scoped delayed event.
The shipped agent guide contains the recommended definition order, modeling rules, lifecycle invariants, React recipe, common compiler errors, and unsupported features.
Use pnpm 10 and Node.js 20 or newer:
pnpm install --frozen-lockfile
pnpm checkIndividual commands are available for build, test, test:types,
typecheck, format:check, test:consumer, and pack:check. Runtime tests use
@effect/vitest; type tests use TSTyche and TypeScript 6.0.3. The consumer check
packs the package, imports all public entrypoints, and compiles a strict
TypeScript consumer with skipLibCheck: false.
Read CONTRIBUTING.md before proposing a change. Pull requests receive an automated base-versus-head type-instantiation report.
The platformer statechart example is a playable SVG demo centered on a schema-first character machine. It demonstrates nested compound locomotion, parallel airborne motion and air-jump regions, independent facing and wall-contact regions, a pause/resume flow backed by typed deep history, typed protocol events, state-scoped timers, and state-driven SVG transforms.
The Pokémon statechart example is a standalone React and
Vite project demonstrating compound and parallel states, state-scoped invokes,
invoked child statecharts, typed emissions, and Atom reactivity. It uses a local
file: dependency on this package while retaining an isolated dependency graph,
lockfile, build, and CI job.
The playground collects focused interactive examples
for traffic lights, turnstiles, media players, microwaves, and worker-backed
machines. CI discovers every direct package under examples/ and runs its
check script automatically.
Add a changeset with pnpm changeset. CI validates frozen installation and the
complete check suite. The release workflow opens version PRs and publishes with
npm provenance through GitHub Actions.
When equivalent Machine modules ship in Effect, this package is intended to become a thin compatibility re-export package before eventual retirement.