Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

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

Install

pnpm add @typeonce/effect-machine effect@4.0.0-beta.107

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

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 the protocol and initializer with Machine.make.
  4. Implement every active state with .handle(...).
  5. 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, 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.

Separate public and internal events

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.

Choose the target by scope

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.

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

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 and AtomMachine.matches for typed, equality-aware derivations. React applications using @effect/atom-react need a RegistryProvider.

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: [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.

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

99 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages