Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

143 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@typeonce/effect-machine

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.

Design principles

  • 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.

Install

pnpm add @typeonce/effect-machine effect@4.0.0-rc.109

effect is an exact peer dependency. Install the version above and upgrade it in lockstep with this package.

Quick start

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.

Modeling workflow

Use this order to preserve inference and keep boundaries explicit:

  1. Define domain, state, public-event, internal-event, and emitted-event schemas.
  2. Declare topology with Machine.defineStates.
  3. Create public and internal event descriptors with Machine.events and Machine.internalEvents.
  4. Create the machine protocol and initializer with Machine.make.
  5. Implement every active state with .handle(...).
  6. Add runtime, Atom, testing, or cluster adapters at the application boundary.

Construct state through builders

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.

Separate inputs, raised events, and emissions

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.start

Machine.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.

Inspect a live machine tree

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.start

For 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.

Send explicitly between machines

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.

Choose the target by scope

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.

Statechart capabilities

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.

Effects, timers, and child machines

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.

Reactivity

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.

Persistence

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.

Testing

The testing entrypoint provides complementary layers:

  • MachineTest.run and verify inspect pure planner traces;
  • invariants and generated scenarios check application laws;
  • explore performs bounded breadth-first state-space exploration;
  • probe causally 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.

Entrypoints

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.

Examples

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.

Reference and development

Use pnpm 10 and Node.js 20 or newer:

pnpm install --frozen-lockfile
pnpm check

Declarative 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.

About

Schema-first state machines and statecharts for Effect

Topics

Resources

Contributing

Stars

114 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages