Type-safe state machines for Effect.
Complex workflows usually fail the same way: one status field, a few side booleans, and effects scattered across callbacks. 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 effect-machine effecteffect is a peer dependency. The repository validates both the v4 entrypoint
and the effect-machine/v3 mirror with @effect/tsgo, the latest Effect beta,
type-aware oxlint, and Bun tests.
States and events are schemas. Types, validation, and serialization from one place.
import{Schema}from"effect";import{Event,Machine,Slot,State}from"effect-machine";constCheckoutState=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},});constCheckoutEvent=Event({Submit: {},Charged: {receiptId: Schema.String},Declined: {reason: Schema.String},Cancel: {},});constCheckoutSlots=Slot.define({chargeCard: Slot.fn({cartId: Schema.String,totalCents: Schema.Number}),});constcheckoutMachine=Machine.make({state: CheckoutState,event: CheckoutEvent,slots: CheckoutSlots,initial: CheckoutState.ReviewingCart({cartId: "cart_123",totalCents: 4200}),}).on(CheckoutState.ReviewingCart,CheckoutEvent.Submit,({ state })=>CheckoutState.ChargingCard.derive(state),).on(CheckoutState.ChargingCard,CheckoutEvent.Charged,({ state, event })=>CheckoutState.Confirmed.derive(state,{receiptId: event.receiptId}),).on(CheckoutState.ChargingCard,CheckoutEvent.Declined,({ state, event })=>CheckoutState.Failed.derive(state,{reason: event.reason}),).onAny(CheckoutEvent.Cancel,({ state })=>CheckoutState.Failed.derive(state,{reason: "cancelled"}),).spawn(CheckoutState.ChargingCard,({ slots, state })=>slots.chargeCard({cartId: state.cartId,totalCents: state.totalCents}),).final(CheckoutState.Confirmed).final(CheckoutState.Failed);A few things to notice:
- Empty variants are values:
State.Idle. Non-empty are constructors:State.Loading({ url }). State.derive(source, overrides)carries overlapping fields forward without manual copying..onAny(...)is a fallback; a specific.on(...)wins..spawn(...)runs work on state entry and cancels it on state exit.
The builder also supports .timeout(state, { duration, event }), .postpone(state, event) for buffering, and .reenter(...) for re-running lifecycle on same-state transitions.
Slots separate what a machine needs from how the app provides it. Declare them on the machine, provide implementations where you run it.
constactor=yield*Machine.spawn(checkoutMachine,{slots: {chargeCard: ({ cartId, totalCents })=>Effect.gen(function*(){constctx=yield*checkoutMachine.Context;constresult=yield*PaymentService.charge(cartId,totalCents);yield*ctx.self.send(result.ok
? CheckoutEvent.Charged({receiptId: result.receiptId})
: CheckoutEvent.Declined({reason: result.error}),);}),},});yield*actor.start;The same machine can run with different slot implementations in tests, local apps, or production. Slots are accepted everywhere the machine runs:
Machine.spawn(machine, { slots })Machine.replay(machine, events, { slots })simulate(machine, events, { slots })createTestHarness(machine, { slots })
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.
constprogram=Effect.gen(function*(){constactor=yield*Machine.spawn(checkoutMachine,{slots: {chargeCard: ({ cartId })=>checkoutMachine.Context.pipe(Effect.flatMap((ctx)=>ctx.self.send(CheckoutEvent.Charged({receiptId: `rcpt_${cartId}`})),),),},});yield*actor.start;yield*actor.send(CheckoutEvent.Submit);constfinalState=yield*actor.awaitFinal;});Effect.runPromise(Effect.scoped(program));Key actor operations:
startforks the event loop (idempotent, required afterMachine.spawn)send(event)queues and returns immediatelycall(event)returns full transition infoask(event)returns a typed domain reply (requiresEvent.reply(...))waitFor(...)/awaitFinalfor coordinationstopinterrupts now;drainprocesses the remaining queue firstwatch(other)completes when another actor stops
For named actors or shared lookup, use an actor system. system.spawn auto-starts — no actor.start needed:
import{ActorSystemDefault,ActorSystemService}from"effect-machine";constprogram=Effect.gen(function*(){constsystem=yield*ActorSystemService;constactor=yield*system.spawn("checkout-123",checkoutMachine);yield*actor.send(CheckoutEvent.Submit);}).pipe(Effect.provide(ActorSystemDefault));Events can declare typed reply schemas:
constCartEvent=Event({GetTotal: Event.reply({},Schema.Number),});machine.on(State.Active,CartEvent.GetTotal,({ state })=>Machine.reply(state,state.totalCents));consttotal=yield*actor.ask(CartEvent.GetTotal);// numberTest transitions without spawning actors:
import{simulate}from"effect-machine";constresult=yield*simulate(checkoutMachine,[CheckoutEvent.Submit,CheckoutEvent.Charged({receiptId: "rcpt_123"})],{slots: {chargeCard: ()=>Effect.void}},);expect(result.states.map((s)=>s._tag)).toEqual(["ReviewingCart","ChargingCard","Confirmed"]);simulate and createTestHarness test transition logic. They do not run .spawn() or .background() effects.
When the same machine needs to run behind @effect/cluster, turn it into an entity:
import{EntityMachine,toEntity}from"effect-machine/cluster";constCheckoutEntity=toEntity(checkoutMachine,{type: "Checkout"});constCheckoutEntityLayer=EntityMachine.layer(CheckoutEntity,checkoutMachine,{initializeState: (entityId)=>CheckoutState.ReviewingCart({cartId: entityId,totalCents: 0}),persistence: {strategy: "journal"},});Persistence strategies:
- Snapshot — saves state periodically, restores on reactivation
- Journal — appends events on each RPC, replays on reactivation
MIT