Schema-first state machines and statecharts for Effect.
State, event, input, output, and persistence boundaries are described with Effect Schema. The same definition can be planned synchronously, run as a managed machine, mounted as an Atom, tested as a model, or hosted by the cluster adapter.
This is early-release software. Its API may change, and each release targets one exact Effect beta.
pnpm add @typeonce/effect-machine effect@4.0.0-beta.107effect is an exact peer dependency. Install the version above and upgrade it
in lockstep with this package.
Define schemas first, derive the state topology, then add behavior:
import { Machine } from "@typeonce/effect-machine"
import { Effect, Schema } from "effect"
const State = Schema.TaggedUnion({
Idle: {},
Running: { count: Schema.Number }
})
const Event = Schema.TaggedUnion({
Start: {},
Increment: {},
Stop: {}
})
const States = Machine.defineStates(State.cases)
const Counter = Machine.make({
id: "Counter",
states: States.states,
events: [Event],
initial: () => States.initial.Idle.from()
}).handle({
Idle: {
on: {
Start: ({ target }) => target.full.Running.from({ count: 0 })
}
},
Running: {
on: {
Increment: ({ state, target }) => target.full.Running.from({ count: state.count + 1 }),
Stop: ({ target }) => target.full.Idle.from()
}
}
})
const program = Effect.gen(function*() {
const ref = yield* Machine.start(Counter)
yield* ref.send(Machine.event(Counter, Event.cases.Start))
yield* ref.send(Machine.event(Counter, Event.cases.Increment))
})Machine.start returns a MachineRef with send, state, snapshot,
changes, join, and stop. Sending enqueues an event; observe changes or
use the testing probe when work must be causally acknowledged.
Use this order to preserve inference and keep boundaries explicit:
- Define domain, state, public-event, internal-event, and emitted-event schemas.
- Declare topology with
Machine.defineStates. - Create the protocol and initializer with
Machine.make. - Implement every active state with
.handle(...). - Add runtime, Atom, testing, or cluster adapters at the application boundary.
Use .from(...) when constructing a new state from fields:
target.local.Saving.from({ draft: event.draft })
States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())The machine runs these inputs through the state schema while planning. Schema
defaults, refinements, and tagged-class identity are therefore preserved, and
decode failures remain typed machine failures. Pass a value directly only when
it is already decoded, such as a value returned by Machine.retag.
Put data on the narrowest state where it is valid. If sibling phases share data, put it on their compound parent.
events is the public command protocol. Invoke results, timer deliveries,
raised events, and child emissions belong in internalEvents:
const Command = Schema.TaggedUnion({ Save: {} })
const Internal = Schema.TaggedUnion({
Saved: { id: Schema.String },
SaveFailed: { message: Schema.String }
})
const machine = Machine.make({
states: States.states,
events: [Command],
internalEvents: [Internal],
initial: () => States.initial.Idle.from()
})Handlers see both protocols. Typed send and Machine.plan accept only public
events. Event tags must be unique and public/internal tags must be disjoint.
Use Machine.event(machine, schema, fields?) for reusable machine-owned event
values. Ordinary objects and schema-constructed values are also accepted and
decoded at the machine boundary.
| Builder | Use when | Preserves |
|---|---|---|
target.local |
Moving inside the nearest compound scope | Ancestors and unrelated parallel regions |
target.branch |
Moving elsewhere under the active root | Omitted active ancestors and parallel regions |
target.full |
Replacing or selecting a complete root | Nothing implicit for a newly selected root |
target.history |
Restoring a declared history node | The remembered configuration or its typed default |
Builders describe the next logical configuration. Shared states exit and enter
only when paths change; use { reenter: true, transition } when the source must
restart even if its path is unchanged.
Machine.defineStates supports:
- atomic states;
- compound states with one active child;
- parallel states with one active state in every region;
- final states and typed outputs;
- transient choice states;
- shallow and deep history states.
Declare topology—including finality, output schemas, choices, and history—only
in defineStates. Handlers implement behavior and output computation without
repeating structural metadata. Final children complete their parent, so
onDone belongs on that compound or parallel parent.
Transition, entry, exit, choice, initial, and history callbacks are
synchronous. Conditions use ordinary TypeScript control flow. Callbacks may
select state and enqueue explicit raise, emit, sendTo, or stop commands;
arbitrary asynchronous Effects do not run inside planning.
State-scoped work starts on entry and is interrupted on exit:
Loading: {
invoke: Machine.invokeEffect({
id: "save-document",
effect: saveDocument,
onSuccess: (entry) => Internal.cases.Saved.make({ id: entry.id }),
onFailure: (error) => Internal.cases.SaveFailed.make({ message: String(error) })
})
}
Waiting: {
invoke: Machine.after(
"3 seconds",
Internal.cases.SaveFailed.make({ message: "Timed out" })
)
}Use Machine.invokeEffect for one Effect, Machine.after for a cancellable
delay, and lower-level Machine.invoke only for custom process behavior or
snapshot mapping. Use one exported Machine.child(id, machine) descriptor for
invokeMachine, sendTo, and child lookup.
Expected failures should become internal events. An unrecovered invoke or child failure terminates the owning runtime.
AtomMachine runs one lazy machine instance per AtomRegistry:
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
import { Atom } from "effect/unstable/reactivity"
const runtime = Atom.runtime(AppLayer)
const counterAtom = AtomMachine.bind(runtime).make(Counter)Binding a shared runtime once is the canonical form for service-backed
applications. Service-free machines can use AtomMachine.make(Counter).
The bridge exposes ref, snapshot, state, fail-aware result, writable
send and stop atoms, and child(descriptor). Use AtomMachine.select and
AtomMachine.matches for typed, equality-aware derivations. React applications
using @effect/atom-react need a RegistryProvider.
Logical snapshots can be validated for storage or transport:
const encoded = yield * Machine.encodeSnapshot(machine, snapshot)
const decoded = yield * Machine.decodeSnapshot(machine, encoded)
const ref = yield * Machine.resume(machine, decoded)Resumption restores logical state, values, completion, and history metadata. It creates a fresh runtime: active invokes restart, timers restart at their full duration, and prior fibers, subscriptions, queues, and child runtimes are not restored. Store machine identity and migration/version metadata beside the encoded snapshot.
The testing entrypoint provides complementary layers:
MachineTest.runandverifyinspect pure planner traces;- invariants and generated scenarios check application laws;
exploreperforms bounded breadth-first state-space exploration;probecausally acknowledges live runtime commands;- runtime command models cover timers, invokes, bursts, and scheduling.
import { MachineTest } from "@typeonce/effect-machine/testing"
const trace = yield* MachineTest.run(Counter, {
events: [Event.cases.Start.make({}), Event.cases.Increment.make({})]
})
yield* MachineTest.verify(Counter, trace)Pure planner tests do not execute invokes or time. Use a started machine and a probe when those semantics matter.
import { Machine } from "@typeonce/effect-machine"
import { ClusterMachine } from "@typeonce/effect-machine/cluster"
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
import { MachineTest } from "@typeonce/effect-machine/testing"Each ESM entrypoint is independent and tree-shakeable.
Every package directly under examples/ has its own lockfile and
check script.
| Example | What it demonstrates |
|---|---|
| Playground | Five focused React examples: atomic turnstile commands, state-scoped traffic-light timers, microwave safety across parallel regions, a service-backed media player, and a worker-hosted machine synchronized across tabs |
| Pokémon | Compound and parallel states, invoked child machines, typed emissions, Atom reactivity, and a live Effect service |
| Platformer | Nested parallel statecharts, typed deep history, raised events, state-scoped timers, deterministic model tests, and a playable SVG adapter |
The playground is the shortest path from one concept to working code. The standalone examples show larger composition and ownership boundaries.
Use pnpm 10 and Node.js 20 or newer:
pnpm install --frozen-lockfile
pnpm checkDeclarative first-class guards are not currently part of the API; use ordinary
TypeScript conditions. Pull requests that change src/ or package.json need
a changeset and the performance checks described in AGENTS.md.
When equivalent Machine modules ship in Effect, this package is intended to become a compatibility re-export before eventual retirement.