effect-machine
v0.17.1
Published
Type-safe state machines for [Effect](https://effect.website).
Readme
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 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.
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:
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";
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); // numberTesting
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
