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.
- Type-safe by construction: reject invalid protocols, compositions, and capabilities at compile time where possible, and preserve typed Effect failures at runtime.
- Explicit and opinionated: give different semantics different names and contracts. Builders and inference remove ceremony without making behavior depend on ambiguous omissions.
- Readable models: keep schemas, topology, behavior, and effects concise enough that a human can understand the complete model from its definition.
- Effect-native: design toward eventual inclusion in Effect core and follow its API shape, module boundaries, ownership, and failure conventions.
The package is pre-1.0: a clearer or safer long-term API takes priority over backward compatibility. Breaking changes use minor releases, compatible fixes use patch releases, and compatibility aliases are not added by default.
The core machine model remains local. Distributed identity, placement, transport, routing, delivery, and remote lifecycle semantics belong to Effect Cluster and are exposed only through explicit integration boundaries.
pnpm add @typeonce/effect-machine effect@4.0.0-rc.109effect 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, Stream } 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 CounterEvent = Machine.events(Event)
const CounterDefinition = Machine.make({
id: "Counter",
states: States.states,
events: CounterEvent,
initial: () => States.initial.Idle.from()
})
const Counter = CounterDefinition.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(CounterEvent.Start())
yield* ref.send(CounterEvent.Increment())
})Machine.start returns a MachineRef with send, state, snapshot,
changes, emissions, 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 public and internal event descriptors with
Machine.eventsandMachine.internalEvents. - Create the machine 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.
When sibling states share fields, remove the source discriminator and pass the remaining fields through the target schema:
Submit: ;
;(({ state, target }) => {
const { _tag: _, ...fields } = state
return target.local.Saving.from({ ...fields, attempt: 1 })
})Omit schema when a state represents control flow but owns no data:
const States = Machine.defineStates({
Form: {
initial: "Editing",
states: {
Editing: {},
Saving
}
}
})
States.initial.Form.from((form) => form.Editing.from())Schema-less states remain active, targetable, matchable, and visible through
getSnapshot, but have no value to read. Their builders expose only .from,
their handler state is undefined, and get / getWithParents accept only
schema-backed paths. Add a schema later if the state starts owning data.
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 machine-input protocol. Events raised to the same machine
belong in internalEvents. Ephemeral outward notifications have their own
emittedEvents protocol:
const Command = Schema.TaggedUnion({ Save: {} })
const Internal = Schema.TaggedUnion({
Saved: { id: Schema.String },
SaveFailed: { message: Schema.String }
})
const Emitted = Schema.TaggedUnion({
SaveObserved: { id: Schema.String }
})
export const CommandEvent = Machine.events(Command)
export type PublicCommandEvent = Machine.EventOf<typeof CommandEvent>
const InternalEvent = Machine.internalEvents(Internal)
const Emissions = Machine.emittedEvents(Emitted)
const definition = Machine.make({
states: States.states,
events: CommandEvent,
internalEvents: InternalEvent,
emittedEvents: Emissions,
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.
Export the descriptor returned by Machine.events instead of exporting its
schemas. This keeps the deferred constructors as the standard way to create
events without exposing schema .make methods:
ref.send(CommandEvent.Save())
enqueue.raise(InternalEvent.Saved({ id: "entry-1" }))
enqueue.emit(Emissions.SaveObserved({ id: "entry-1" }))The returned constructors preserve each schema's make input, including required
fields and constructor defaults. They defer schema construction until delivery,
so invalid values fail planning or the running machine with
MachineSchemaDecodeError instead of throwing at the call site.
Schemas with an open discriminator such as _tag: Schema.String remain valid
protocols but cannot expose a finite constructor set; pass a complete event
object to send or Machine.plan for those events.
ref.emissions is a hot Stream: it publishes only notifications produced
after subscription, replays nothing, and completes when the machine terminates.
Snapshots remain separate and stateful: ref.changes begins with the current
lifecycle snapshot and then follows later changes. Use Machine.prepare when
an observer must be installed before initial-entry actions run:
const prepared = yield * Machine.prepare(machine)
yield * prepared.emissions.pipe(
Stream.runForEach(handleEmission),
Effect.forkScoped({ startImmediately: true })
)
const ref = yield * prepared.startMachine.start(machine) remains the one-step convenience for callers that do
not observe startup emissions. Preparation does not retain or replay an
emission: the observer is simply subscribed before initialization begins.
Machine.prepare(machine).inspection is the operational counterpart to the
domain-facing changes and emissions streams. It observes the prepared root
and every locally owned child, Logic process, Effect, and timer in one total
publication order:
const prepared = yield * Machine.prepare(checkout)
yield * prepared.inspection.pipe(
Stream.runForEach((record) => Console.log(record.sequence, record.subject.id, record._tag)),
Effect.forkScoped({ startImmediately: true })
)
const checkoutRef = yield * prepared.startFor a handled input, the stream may expose values such as:
{ _tag: "EventSent", sequence: 2, deliveryId: 0,
subject: { id: "checkout", sessionId: "machine:0", kind: "Machine" },
source: undefined, target: { id: "checkout", sessionId: "machine:0" },
event: CheckoutEvents.Submit(), causedBy: undefined }
{ _tag: "EventProcessed", sequence: 4, macrostepId: 0,
deliveryId: 0, handled: true, configurationChanged: true,
before: { status: "active", state: /* ... */ },
after: { status: "active", state: /* ... */ }, microsteps: [/* ... */] }The closed Machine.Inspection.Event union also reports creation,
initialization and startup failure, direct Logic state updates, outward
emissions, Effect/timer activity lifecycles, and termination. Records erase
unrelated child protocols to unknown; application-level observation remains
typed through each reference's changes and emissions.
The stream is hot, non-replayed, never fails, and completes after the root
terminates. Subscribe before prepared.start to capture initialization. Local
session ids are unique only inside that prepared ownership tree: machine:0
is the root and later ids identify its descendants. They are intentionally not
distributed identities. Cluster placement, routing, and correlation continue
to use Cluster entity, runner, and request identities at the integration
boundary.
AtomMachine.inspection(machineAtom) provides the same root-scoped stream and
starts a fresh atom-backed machine only after its inspection subscription is
installed.
Invalid event and emission constructions fail the machine with a typed
MachineSchemaDecodeError; they do not throw from the constructor call.
raise targets the current machine in the same macrostep. sendTo targets a
machine mailbox and is processed later. A child declares the subset of parent
inputs it may send with parentEvents:
const ParentEvents = Machine.events(ChildFinished)
const child = Machine.make({
states: ChildStates.states,
events: ChildEvents,
parentEvents: ParentEvents,
initial: () => ChildStates.initial.Working.from()
}).handle({
Working: {
on: {
Finish: ({ parent, target }, enqueue) => {
if (parent !== undefined) {
enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
}
return target.full.Done.from()
}
}
},
Done: {}
})
const Child = Machine.child("worker", child)
const ParentInputs = Machine.events(Start, ParentEvents)The same child remains isolated and may be started as a root, where parent is
undefined. When Child is invoked, the parent definition must accept every
event in parentEvents; otherwise .handle(...) is a compile-time error.
Inside the child, the parent target accepts only those declared events.
emit never sends to the parent: it only publishes on the emitting machine's
emissions stream.
Every handler also receives self, which can be targeted with sendTo when a
later mailbox turn is required. Use raise instead for same-macrostep work.
Both self and parent are minimal Machine.MachineTarget<Event> values. The
shared Machine.MachineReferences<InputEvents, ParentEvents> context keeps
their input protocols separate without exposing snapshot or lifecycle APIs.
Structural state values use distinct names: containingState is the immediate
valued state in the same statechart, while ancestors maps valued ancestor
paths. parent always means the owning machine target.
| Builder | Use when | Preserves |
|---|---|---|
target.none() |
Handling without selecting a destination | The complete current configuration |
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 |
Every installed transition handler returns either a concrete target or
target.none(). An absent handler ignores the trigger; target.none() handles
it and retains queued commands, raised events, and emitted events without
selecting a destination. Declared targets constrain only concrete
destinations, so target.none() is always permitted. Builders describe the
next logical configuration. Shared states exit and enter only when paths
change; use { reenter: true, transition } when the source must restart. With
target.none(), reentry restarts the source while retaining its configuration.
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.invoke({
id: "save-document",
effect: () => saveDocument,
onDone: ({ output, target }) => target.full.Saved({ id: output.id }),
onFailure: ({ error, target }) => target.full.Failed({ message: String(error) })
})
}
Waiting: {
invoke: Machine.invoke({
id: "save-timeout",
after: "3 seconds",
onDone: ({ target }) => target.full.Failed({ message: "Timed out" })
})
}Use effect for one Effect, after for a cancellable delay, logic for a
reusable process, and child for a complete child statechart—all through
Machine.invoke({...}). The helper is an identity at runtime and preserves
owner-context and source-channel inference across lifecycle handlers, including
for state-dependent Effects:
invoke: Machine.invoke({
id: "load-document",
effect: ({ state }) => loadDocument(state.documentId),
onDone: ({ output, target }) => target.full.Ready({ document: output }),
onFailure: ({ error, target }) => target.full.Failed({ message: error.message })
})The standalone Machine.invoke(...) constructor does not know the owning
definition, so its self and parent references are non-sendable. When an
invocation callback sends through either reference, construct it through the
owning definition so those references use its exact public input and
parentEvents protocols:
const definition = Machine.make({
events: Commands,
internalEvents: InternalEvents,
parentEvents: ParentEvents
// ...
})
const machine = definition.handle({
Saving: {
invoke: definition.invoke({
id: "notify-parent",
effect: ({ parent }) =>
parent === undefined
? Effect.void
: parent.send(ParentEvents.SaveStarted()),
onDone: ({ target }) => target.none(),
onFailure: ({ target }) => target.none()
})
}
})A direct invoke: { ... } object is also supported when its lifecycle handlers
do not need source-derived context. Reuse one exported
Machine.child(id, machine) descriptor for invocation, sendTo, and child
lookup.
onDone is required for a non-never output, and onFailure is required for a
non-never typed error; each handler is omitted when its channel is never.
Defects, interruption, and source-construction failures terminate the owning
runtime. Effect sources are always factories evaluated when their state is
entered. Use effect: () => Effect.sleep(...) for a generic Effect, while
after keeps timers explicit and makes static durations visible through
activity inspection.
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,
AtomMachine.selectSnapshot, and AtomMachine.matches for typed,
equality-aware derivations. React applications using @effect/atom-react need
a RegistryProvider.
Emissions stay streams rather than becoming retained atom state:
const rootEmissions = AtomMachine.emissions(counterAtom)
const childEmissions = AtomMachine.childEmissions(counterAtom.child(Worker))These streams require the same AtomRegistry, follow the currently mounted
machine instance, and do not replay notifications from an earlier subscription
or child instance.
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: [
{ _tag: "Start" },
{ _tag: "Increment" }
]
})
yield* MachineTest.verify(Counter, trace)MachineTest scenarios retain decoded event values for model inspection, so
pass complete decoded objects when defining scenarios manually. 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.