Type-safe state machines and actors for Effect.
Complex workflows usually fail the same way: one status field, a few side booleans, and effects scattered across callbacks. @humanlayer/effect-machine gives you one typed model for state, events, and transitions, then runs it as a real actor.
Use it when a feature has:
- multiple valid and invalid states
- async work tied to state entry
- retries, timeouts, cancellation, or backpressure
- logic you want to reuse in-process, in tests, and in distributed systems
bun add @humanlayer/effect-machine effecteffect is a peer dependency. This package is validated against the latest Effect v4 beta.
States and events are schemas. Types, validation, and serialization come from one place.
import { Cause, Context, Effect, Layer, Schema } from "effect";
import { Event, Machine, State } from "@humanlayer/effect-machine";
class Payments extends Context.Service<
Payments,
{
readonly charge: (
cartId: string,
totalCents: number,
) => Effect.Effect<{ readonly receiptId: string }, Error>;
}
>()("@app/Payments") {}
const CheckoutState = State({
ReviewingCart: { cartId: Schema.String, totalCents: Schema.Number },
ChargingCard: { cartId: Schema.String, totalCents: Schema.Number },
Confirmed: { cartId: Schema.String, receiptId: Schema.String },
Failed: { cartId: Schema.String, reason: Schema.String },
});
const CheckoutEvent = Event({
Submit: {},
Charged: { receiptId: Schema.String },
Declined: { reason: Schema.String },
Cancel: {},
});
const checkoutMachine = Machine.make({
state: CheckoutState,
event: CheckoutEvent,
initial: CheckoutState.ReviewingCart({ cartId: "cart_123", totalCents: 4200 }),
})
.on(CheckoutState.ReviewingCart, CheckoutEvent.Submit, ({ state }) =>
CheckoutState.ChargingCard.with(state),
)
.task(
CheckoutState.ChargingCard,
({ state }) =>
Effect.gen(function* () {
const payments = yield* Payments;
return yield* payments.charge(state.cartId, state.totalCents);
}),
{
onSuccess: (result) => CheckoutEvent.Charged({ receiptId: result.receiptId }),
onFailure: (cause) => CheckoutEvent.Declined({ reason: Cause.pretty(cause) }),
},
)
.on(CheckoutState.ChargingCard, CheckoutEvent.Charged, ({ state, event }) =>
CheckoutState.Confirmed.with(state, { receiptId: event.receiptId }),
)
.on(CheckoutState.ChargingCard, CheckoutEvent.Declined, ({ state, event }) =>
CheckoutState.Failed.with(state, { reason: event.reason }),
)
.onAny(CheckoutEvent.Cancel, ({ state }) =>
CheckoutState.Failed.with(state, { reason: "cancelled" }),
)
.final(CheckoutState.Confirmed)
.final(CheckoutState.Failed);A few things to notice:
- Empty variants are values:
State.Idle. Non-empty variants are constructors:State.Loading({ url }). State.with(source, overrides)carries overlapping fields forward without manual copying..onAny(...)is a fallback; a specific.on(...)wins..task(...)runs work on state entry, sends mapped completion events, and cancels work on state exit.
The fluent builder keeps state behavior beside the transitions that make it relevant:
.on([State.Draft, State.Review], Event.Cancel, handler)registers one transition for multiple states;.from(state, scope => ...)groups transitions by source state..reenter(...)runs state lifecycle again when a transition keeps the same state tag. Ordinary same-state transitions do not restart state effects or timers..spawn(state, handler)forks state-scoped work that is interrupted on state exit..background(handler)runs for the actor lifetime..timeout(state, { duration, event })starts a state-scoped timer; leaving the state cancels it. Bothdurationandeventcan derive from the entered state..postpone(state, event)buffers matching events and drains them in FIFO order after the next state-tag change.
Use self.send(...) from a state effect to feed work back into the machine. State effects can use Effect services and can be asynchronous; transition handlers stay pure.
New machines use Effect's service system for dependencies, not actor-local slot maps. Define a dependency with Context.Service (the Effect v4 replacement for ServiceMap.Service), access it with yield* inside a state effect, and provide an implementation with a Layer at the program boundary.
Requirements from .task(), .spawn(), and .background() are inferred by the machine and flow through Machine.spawn, system.spawn, and EntityMachine.layer. Transition handlers remain pure: they cannot require services or fail. Move I/O into a state effect and communicate its outcome with an event.
const PaymentsLive = Layer.succeed(Payments, {
charge: (cartId, totalCents) => Effect.succeed({ receiptId: `rcpt_${cartId}_${totalCents}` }),
});
const program = Effect.gen(function* () {
const actor = yield* Machine.spawn(checkoutMachine);
yield* actor.start;
yield* actor.send(CheckoutEvent.Submit);
return yield* actor.awaitFinal;
}).pipe(Effect.provide(PaymentsLive));This also makes testing conventional Effect code: provide a test layer around the actor program. simulate and createTestHarness do not run state effects, so they do not require their services.
Slot, Machine.make({ slots }), handler ({ slots }), and { slots } spawn options remain as deprecated compatibility APIs. Use them only while migrating an existing machine; they are not the DI mechanism for new code.
| Legacy slot pattern | Effect service replacement |
|---|---|
Slot.define({ charge: Slot.fn(...) }) |
class Payments extends Context.Service<...>()("@app/Payments") {} |
Machine.make({ ..., slots }) |
Read the service in .task(...), .spawn(...), or .background(...) |
Machine.spawn(machine, { slots }) |
Machine.spawn(machine).pipe(Effect.provide(PaymentsLive)) |
system.spawn(id, machine, { slots }) |
Provide PaymentsLive around the program that calls system.spawn(...) |
Legacy slot handlers must still be supplied explicitly at every execution boundary that uses them, such as Machine.spawn, system.spawn, simulate, createTestHarness, and Machine.replay. Their dependencies are not inferred through the machine type, so migrate them to Effect services when possible.
Declare a reply schema on an event to make it valid for actor.ask(...). Its transition returns Machine.reply(nextState, value), so the reply type is inferred from the schema.
const ReceiptEvent = Event({
GetReceipt: Event.reply({}, Schema.String),
Cancel: {},
});
const machine = Machine.make({
state: CheckoutState,
event: ReceiptEvent,
initial: CheckoutState.Confirmed({ cartId: "cart_123", receiptId: "rcpt_123" }),
})
.on(CheckoutState.Confirmed, ReceiptEvent.GetReceipt, ({ state }) =>
Machine.reply(state, state.receiptId),
)
.onAny(ReceiptEvent.Cancel, ({ state }) =>
CheckoutState.Failed.with(state, { reason: "cancelled" }),
);
const receiptId = yield * actor.ask(ReceiptEvent.GetReceipt);ask fails with NoReplyError when a handler does not reply and ActorStoppedError when the actor stops first. For a reply produced later by state work, return Machine.deferReply(state) from the transition and call self.reply(value) from a .spawn(...) handler.
Machine.spawn allocates an actor but does not start it. Call actor.start to fork the event loop, background effects, and spawn effects. Events sent before start() are queued.
Key actor operations:
startforks the event loop and entry effectssend(event)queues and returns immediatelycall(event)returns full transition infoask(event)returns a typed domain reply fromEvent.reply(...)waitFor(...)andawaitFinalcoordinate with state changesstopinterrupts now;drainprocesses remaining queued events firstsnapshot,matches, andcaninspect the current actor statechangesandtransitionsexpose state and transition streams;subscribeprovides a synchronous listener
Use Machine.scoped when a local actor should stop with an Effect scope. The scope bridge is explicit, so unrelated scopes never stop an actor by accident.
const program = Effect.scoped(
Machine.scoped(
Effect.gen(function* () {
const actor = yield* Machine.spawn(checkoutMachine);
yield* actor.start;
yield* actor.send(CheckoutEvent.Submit);
return yield* actor.awaitFinal;
}),
),
);For named actors or shared lookup, use an actor system. system.spawn auto-starts the actor:
import { ActorSystemDefault, ActorSystemService } from "@humanlayer/effect-machine";
const program = Effect.gen(function* () {
const system = yield* ActorSystemService;
const actor = yield* system.spawn("checkout-123", checkoutMachine);
yield* actor.send(CheckoutEvent.Submit);
}).pipe(Effect.provide(ActorSystemDefault), Effect.provide(PaymentsLive));ActorSystemService also exposes get(id), stop(id), a snapshot actors map, an event Stream, and subscribe(...) for synchronous ActorSpawned, ActorRestarted, and ActorStopped notifications.
Local actors can resolve an initial state during start() and persist committed transitions with lifecycle hooks. hydrate takes precedence over recovery, which is useful when a caller already has authoritative state.
import { Option } from "effect";
import { Supervision } from "@humanlayer/effect-machine";
const actor =
yield *
Machine.spawn(checkoutMachine, {
lifecycle: {
recovery: {
resolve: ({ actorId }) =>
storage.load(actorId).pipe(Effect.map(Option.fromNullable), Effect.orDie),
},
durability: {
save: ({ actorId, nextState, event }) =>
storage.save(actorId, nextState, event).pipe(Effect.orDie),
shouldSave: (nextState, previousState) => nextState._tag !== previousState._tag,
},
},
supervision: Supervision.restart({ maxRestarts: 3, within: "1 minute" }),
});
yield * actor.start;Recovery receives the actor ID, generation, and machine initial state, and returns Option<State>. Durability runs after a transition commits and receives both states, the event, actor ID, and generation. A supervised defect restarts from recovered state when available, otherwise from machine.initial; explicit stop, drain, and final states are terminal. Use actor.awaitExit to observe a terminal Final, Stopped, or Defect exit.
State effects can create children with self.spawn(id, machine). Children created by .spawn(...) are state-scoped and automatically stop when their parent leaves that state.
const parentMachine = Machine.make({
state: ParentState,
event: ParentEvent,
initial: ParentState.Idle,
})
.on(ParentState.Idle, ParentEvent.Start, () => ParentState.Running)
.spawn(ParentState.Running, ({ self }) =>
Effect.gen(function* () {
const child = yield* self.spawn("worker-1", workerMachine).pipe(Effect.orDie);
yield* child.send(WorkerEvent.Start);
}),
);Use .background(...) when a child should live for the whole actor lifetime. Each actor exposes its direct children through actor.children and can look up actors through actor.system.
Provide the optional InspectorService to observe actor spawn, received events, transitions, tasks, effects, defects, and stops. Built-ins include consoleInspector, collectingInspector, and tracingInspector.
import { consoleInspector, InspectorService } from "@humanlayer/effect-machine";
const program = Effect.gen(function* () {
const actor = yield* Machine.spawn(checkoutMachine);
yield* actor.start;
yield* actor.send(CheckoutEvent.Submit);
}).pipe(Effect.provideService(InspectorService, consoleInspector()));Test state transitions without starting an actor:
import { simulate } from "@humanlayer/effect-machine";
const result =
yield *
simulate(checkoutMachine, [
CheckoutEvent.Submit,
CheckoutEvent.Charged({ receiptId: "rcpt_123" }),
]);
expect(result.states.map((state) => state._tag)).toEqual([
"ReviewingCart",
"ChargingCard",
"Confirmed",
]);simulate and createTestHarness run transition logic but do not run .task(), .spawn(), or .background() effects.
For focused assertions, assertPath, assertReaches, and assertNeverReaches are built on the same simulation model. Test state effects with a real actor instead.
Run the same machine behind @effect/cluster:
import { EntityMachine, toEntity } from "@humanlayer/effect-machine/cluster";
const CheckoutEntity = toEntity(checkoutMachine, { type: "Checkout" });
const CheckoutEntityLayer = EntityMachine.layer(CheckoutEntity, checkoutMachine, {
initializeState: (entityId) => CheckoutState.ReviewingCart({ cartId: entityId, totalCents: 0 }),
persistence: { strategy: "journal" },
});toEntity requires a machine made with Machine.make({ state, event, initial }), then creates Send, Ask, GetState, and WatchState RPCs. makeEntityActorRef(client, entityId) wraps that protocol with a typed send, ask, snapshot, watch, and waitFor API.
Persistence is opt-in and resolves PersistenceAdapter from the entity layer's services:
- Snapshot is the default. It saves on each state change unless
snapshotSchedulecontrols the cadence, then restores on reactivation. - Journal appends every
SendandAskevent inline, replays events after the latest snapshot, and saves a snapshot when the entity deactivates.
Entity options also include maxIdleTime, mailboxCapacity, defectRetryPolicy, and disableFatalDefects, which are forwarded to @effect/cluster.
MIT