Skip to content

cevr/effect-machine

Repository files navigation

effect-machine

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

Install

bun add effect-machine effect

Core Pattern

States and events are schemas. Types, validation, and serialization from one place.

import { Schema } from "effect";
import { Event, Machine, Slot, State } from "effect-machine";

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 CheckoutSlots = Slot.define({
  chargeCard: Slot.fn({ cartId: Schema.String, totalCents: Schema.Number }),
});

const checkoutMachine = 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

Slots separate what a machine needs from how the app provides it. Declare them on the machine, provide implementations where you run it.

const actor =
  yield *
  Machine.spawn(checkoutMachine, {
    slots: {
      chargeCard: ({ cartId, totalCents }) =>
        Effect.gen(function* () {
          const ctx = yield* checkoutMachine.Context;
          const result = 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 })

Running Actors

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.

const program = Effect.gen(function* () {
  const actor = 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);
  const finalState = yield* actor.awaitFinal;
});

Effect.runPromise(Effect.scoped(program));

Key actor operations:

  • start forks the event loop (idempotent, required after Machine.spawn)
  • send(event) queues and returns immediately
  • call(event) returns full transition info
  • ask(event) returns a typed domain reply (requires Event.reply(...))
  • waitFor(...) / awaitFinal for coordination
  • stop interrupts now; drain processes the remaining queue first
  • watch(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";

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));

Typed Replies

Events can declare typed reply schemas:

const CartEvent = Event({
  GetTotal: Event.reply({}, Schema.Number),
});

machine.on(State.Active, CartEvent.GetTotal, ({ state }) => Machine.reply(state, state.totalCents));

const total = yield * actor.ask(CartEvent.GetTotal); // number

Testing

Test transitions without spawning actors:

import { simulate } from "effect-machine";

const result =
  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.

Cluster

When the same machine needs to run behind @effect/cluster, turn it into an entity:

import { EntityMachine, toEntity } from "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" },
});

Persistence strategies:

  • Snapshot — saves state periodically, restores on reactivation
  • Journal — appends events on each RPC, replays on reactivation

License

MIT

About

Schema-first state machines for Effect. Define once, derive everywhere, break nothing.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors