- Book: TypeScript in Production
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Picture a team running a 4-service order pipeline. Inventory
reserves the SKU, payment charges the card, shipping creates the
label, and notification sends the confirmation email. Four calls
inside one Express handler, wrapped in a try/catch that logged
and returned a 500.
The first time the shipping API returns a 504 in production, the
card has been charged, the inventory still locked, no email
sent. The order row flips to failed. Three side-effects landed
in the world, nothing rolls them back, and support spends the
morning manually refunding cards and releasing stock from a Slack
thread.
That handler needed a saga. Specifically the orchestrator-style
version, where one process owns the workflow, runs each step,
and walks the completed steps backwards if a later one fails.
The pattern goes back to Garcia-Molina and Salem's 1987 paper.
The shape that fits TypeScript today is the one Effect gives you:
typed errors in the channel, a release hook that fires on every
failure path, and not much else. Below is a working order saga
in roughly 150 lines on Effect 3.x. The 3.0 release post
is the entry point if you have not touched it.
Saga vs distributed transaction
A distributed transaction wants ACID across services. Two-phase
commit, prepared writes, a coordinator that holds locks while it
votes. It works in textbooks and in the same database. Across HTTP
boundaries it falls apart: one participant crashes mid-vote, the
coordinator times out, and you are stuck with held locks on
inventory rows nobody can release.
A saga gives up the all-or-nothing semantics. Each step commits
locally. If a later step fails, the saga runs a compensating
action for every step that already succeeded, in reverse. The
result is eventual consistency: the system passes through
intermediate states a strict two-phase commit would never expose,
but it never deadlocks waiting on a coordinator that left the chat.
For an order workflow, the trade is correct. No business reason
demands every step commit at the same instant. Every business
reason demands a charged card be matched by either a shipped
order or a refund.
What Effect brings to the saga
A saga in plain promises and async/await is doable. It is also
full of branches you have to remember to write: every step needs
a try/catch, every catch needs to call the right compensations in
the right order, every retry threads the idempotency key.
Effect collapses most of that into three primitives:
-
Effect.genis a generator-based syntax that reads likeasync/awaitbut tracks the error channel and the requirement channel in the type system. Theyield*lines look sequential; the underlying program is a typed description of work. The current docs cover the Effect.gen pattern end to end. -
Typed errors via
Effect.failmean every step declares the errors it can produce. You do not get atry/catchoverunknown. You get a union you have to handle, and the compiler refuses to forget one. -
Effect.acquireUseReleaseis the structured-concurrency hook that runs a release function whether the use block succeeds, fails, or is interrupted. It is the natural home for a compensating action. The resource management docs describe the contract.
Together they let you write the forward path of the saga as a
single generator and let Effect's runtime guarantee that
compensations fire on every failure path you might otherwise
forget.
The four steps
The order saga has four forward steps. Each one calls one external
service and produces some piece of state the next step needs. Each
one has a compensating action that undoes its effect.
reserveInventory -> compensate: releaseInventory
chargePayment -> compensate: refundPayment
createShipment -> compensate: cancelShipment
sendConfirmation -> compensate: (none — best-effort)
The notification step has no compensation because the email is the
last thing that happens. If the saga reaches sendConfirmation,
every preceding side-effect already landed. A failed email is
handled out of band by the email provider's retry, not by the saga.
The idempotency key for every step is derived from one stable saga
ID and the step name. That way, calling chargePayment twice on
the same saga reaches the same charge record at Stripe. Calling
releaseInventory twice releases the same reservation. Retries are
safe by construction, not by hope.
import { Effect, Data } from "effect"
type SagaInput = {
readonly sagaId: string
readonly customerId: string
readonly sku: string
readonly quantity: number
readonly amountCents: number
readonly address: string
readonly email: string
}
const stepKey = (sagaId: string, step: string) =>
`${sagaId}:${step}`
That stepKey helper is the entire idempotency contract. Every
external call threads the same string into whatever idempotency
header the downstream service supports: Stripe's
Idempotency-Key, the inventory service's X-Request-Id, the
shipping API's order reference. One key per step per saga,
deterministic across retries.
Typed errors
Effect's error channel is a sum type. Each step declares the
errors it can fail with. The orchestrator's type inference unions
them. There is no untyped catch (e: unknown) to remember to
match.
class InventoryError extends Data.TaggedError(
"InventoryError",
)<{ message: string }> {}
class PaymentError extends Data.TaggedError(
"PaymentError",
)<{ message: string; declined: boolean }> {}
class ShipmentError extends Data.TaggedError(
"ShipmentError",
)<{ message: string }> {}
class NotificationError extends Data.TaggedError(
"NotificationError",
)<{ message: string }> {}
Data.TaggedError is Effect's helper for the standard
discriminated-union shape. Every error carries a _tag literal
the runtime can match on. The reason this matters more in a saga
than in a normal HTTP handler is that compensation logic is
sometimes error-specific. A PaymentError with declined: true
does not need a refund; the charge never happened. A
PaymentError with declined: false (a 500 from the gateway)
might need one, because the charge could have landed before the
response timed out. Note: your gateway's declined-error contract
will differ — the code === "card_declined" check below is
illustrative, not a literal Stripe SDK shape.
Each step is an Effect that succeeds with whatever state the next
step needs and fails with its specific tagged error. The bodies
below use Effect.tryPromise to lift the existing
service clients (the ones the team already had, fetch wrappers
around four different APIs) into the Effect world. The
Effect.tryPromise docs
spell out the failure-channel mapping.
const reserveInventory = (input: SagaInput) =>
Effect.tryPromise({
try: () => inventoryClient.reserve({
idempotencyKey: stepKey(input.sagaId, "reserve"),
sku: input.sku,
quantity: input.quantity,
}),
catch: (cause) => new InventoryError({
message: String(cause),
}),
})
const chargePayment = (input: SagaInput) =>
Effect.tryPromise({
try: () => paymentClient.charge({
idempotencyKey: stepKey(input.sagaId, "charge"),
customerId: input.customerId,
amountCents: input.amountCents,
}),
catch: (cause: unknown) => {
const declined =
typeof cause === "object" &&
cause !== null &&
(cause as { code?: string }).code === "card_declined"
return new PaymentError({
message: String(cause),
declined,
})
},
})
const createShipment = (input: SagaInput) =>
Effect.tryPromise({
try: () => shippingClient.create({
idempotencyKey: stepKey(input.sagaId, "ship"),
address: input.address,
sku: input.sku,
}),
catch: (cause) => new ShipmentError({
message: String(cause),
}),
})
const sendConfirmation = (input: SagaInput) =>
Effect.tryPromise({
try: () => notificationClient.send({
idempotencyKey: stepKey(input.sagaId, "notify"),
to: input.email,
}),
catch: (cause) => new NotificationError({
message: String(cause),
}),
})
Each try runs the existing Promise-returning client. Each catch
maps the unknown rejection into a tagged error. From this point on,
the orchestrator only sees the tagged shapes; no unknown leaks
into the error channel.
The compensating actions
Each compensation is its own Effect. They run when a later step
fails. They use idempotency keys derived the same way, so a
compensation that runs twice (because the orchestrator itself
crashed mid-rollback and resumed) hits the same downstream record.
const releaseInventory = (input: SagaInput) =>
Effect.tryPromise({
try: () => inventoryClient.release({
idempotencyKey: stepKey(input.sagaId, "reserve") + ":undo",
sku: input.sku,
quantity: input.quantity,
}),
catch: (cause) => new InventoryError({
message: String(cause),
}),
}).pipe(Effect.orElse(() => Effect.void))
const refundPayment = (input: SagaInput) =>
Effect.tryPromise({
try: () => paymentClient.refund({
idempotencyKey: stepKey(input.sagaId, "charge") + ":undo",
customerId: input.customerId,
amountCents: input.amountCents,
}),
catch: (cause) => new PaymentError({
message: String(cause),
declined: false,
}),
}).pipe(Effect.orElse(() => Effect.void))
const cancelShipment = (input: SagaInput) =>
Effect.tryPromise({
try: () => shippingClient.cancel({
idempotencyKey: stepKey(input.sagaId, "ship") + ":undo",
}),
catch: (cause) => new ShipmentError({
message: String(cause),
}),
}).pipe(Effect.orElse(() => Effect.void))
Two design choices in this block worth calling out.
The :undo suffix on the compensation idempotency key is what
keeps the forward call and the reverse call distinct. Most
payment providers, including Stripe per their idempotency
docs, reject a
refund that reuses the charge's idempotency key because they
treat it as a duplicate of the original request. A separate
suffix for the compensation gives the provider a fresh slot to
record the refund.
The Effect.orElse(() => Effect.void) swallows the error after the
compensation tries its best. The reason is bounded: when
compensation fails, the saga is already failing. You record the
failure for an operator and move on rather than letting one
half-broken refund prevent the others from running. In a real
production system this is where you would emit a metric ("failed
compensation") and a row in a stuck_sagas table that pages a
human. The orchestrator should never block on a compensation
that the downstream cannot accept.
The orchestrator
Effect.acquireUseRelease is the structured-concurrency primitive
that runs the release function whether the use block succeeded or
failed. The saga uses one acquireUseRelease per step: the acquire
runs the forward action, the release runs the compensation if (and
only if) the rest of the saga fails.
const placeOrder = (input: SagaInput) =>
Effect.gen(function* () {
const reservation = yield* Effect.acquireUseRelease(
reserveInventory(input),
(_reservation) => Effect.gen(function* () {
const charge = yield* Effect.acquireUseRelease(
chargePayment(input),
(_charge) => Effect.gen(function* () {
const shipment = yield* Effect.acquireUseRelease(
createShipment(input),
(_shipment) =>
sendConfirmation(input).pipe(
Effect.orElse(() => Effect.void),
),
(_shipment, exit) =>
exit._tag === "Failure"
? cancelShipment(input)
: Effect.void,
)
return shipment
}),
Charge sits inside the reservation acquire. Its release runs only
if the inner block fails, which is what makes the rollback walk
the steps backwards without an extra control structure.
(_charge, exit) =>
exit._tag === "Failure"
? refundPayment(input)
: Effect.void,
)
return charge
}),
(_reservation, exit) =>
exit._tag === "Failure"
? releaseInventory(input)
: Effect.void,
)
return reservation
})
Read it from the outside in. The outermost acquireUseRelease
acquires a reservation. Its release function checks the Exit
(Effect's representation of how the inner block completed) and
runs releaseInventory only if the inner block failed. Inside,
a second acquireUseRelease charges the card; its release runs
refundPayment on failure. Inside that, a third creates the
shipment and runs cancelShipment on failure. The innermost
block is the notification, wrapped in
Effect.orElse(() => Effect.void) because a failed email does
not invalidate the order.
If chargePayment fails, the outer release sees Failure and
calls releaseInventory. If createShipment fails, both outer
releases see Failure: cancelShipment does not run (no shipment
to cancel — the failure happened in the acquire), refundPayment
runs, then releaseInventory runs. The compensations walk
backwards in the exact reverse order of the successful steps,
because that is what nesting means.
The detail that earns Effect its keep here is that
acquireUseRelease runs the release on interruption too. If
the parent fiber is cancelled mid-saga (a deploy, a request
timeout, a Ctrl-C), the partial work still gets unwound. The
plain Promise version of this code with try/finally does not
give you that guarantee, because await does not honour
cancellation tokens by default.
Running it
Effect's runtime takes the description and turns it into a real
program. Effect.runPromise is the bridge to a Node, Bun, or Deno
host that wants a Promise back:
import { Effect } from "effect"
const result = await Effect.runPromise(
placeOrder({
sagaId: crypto.randomUUID(),
customerId: "cus_123",
sku: "BOOK-001",
quantity: 1,
amountCents: 2999,
address: "1 Infinite Loop, Cupertino",
email: "buyer@example.com",
}).pipe(
Effect.catchTags({
InventoryError: (e) => Effect.succeed({
ok: false, reason: "inventory" as const, message: e.message,
}),
PaymentError: (e) => Effect.succeed({
ok: false, reason: "payment" as const, message: e.message,
}),
ShipmentError: (e) => Effect.succeed({
ok: false, reason: "shipment" as const, message: e.message,
}),
NotificationError: (e) => Effect.succeed({
ok: false, reason: "notification" as const, message: e.message,
}),
}),
),
)
Effect.catchTags matches each tagged error variant separately
and returns an Effect that is now infallible. The error channel
is never; the success channel is the union of "the saga
succeeded" and "the saga failed cleanly with a known reason." The
HTTP handler that wraps this can pattern-match on result.ok and
return the right status.
Where this falls down
Two failure modes the in-process orchestrator above does not solve.
Long-running sagas. If any step takes minutes (a manual
warehouse pick, a fraud-review hold, a third-party batch settlement
that runs hourly), holding an Effect fiber open is the wrong shape.
The fiber lives in one process. A deploy kills it. A pod restart
kills it. The saga vanishes mid-flight with no row to resume from.
The fix is to persist the saga state to a queue plus a database —
BullMQ on Redis for the job orchestration,
Postgres for the saga state and idempotency keys. Each step becomes a
job. The compensation is its own job. The orchestrator becomes a
state machine that runs in the worker, not a generator that runs in
the request.
Retries within a step. The saga above has no per-step retry. A
transient 503 from the inventory service fails the saga and triggers
compensation. Often the right move is to retry the step a few
times before giving up. Effect ships Effect.retry with rich
schedules: exponential backoff, jitter, deadline-bounded. You
would wrap each step's acquire in something like
reserveInventory(input).pipe(Effect.retry(retryPolicy)). The
Effect.retry docs
cover the schedule combinators. Adding the right retry policy
keeps the saga from rolling back over a hiccup that a 200ms wait
would have fixed.
Both of those are layers on top of the shape this post sketches,
not refutations of it. The four-step skeleton with
acquireUseRelease and tagged errors is the kernel. Persistence
turns it into a long-running engine. Retries turn it into a
production-grade engine. Either is a chapter of its own.
The thing that earns Effect its keep here
The reason this saga is shorter than the same code in plain
async/await is not that Effect is magic. The compensating
action is data-attached to the acquire, so there is no place in
the orchestrator where you need to remember to call the rollback.
The control flow lives in the structured-concurrency primitive.
Forgetting it is no longer possible because there is no separate
slot to forget it in.
Take the same model further. Wrap the saga in a span and every
step becomes a child span, every compensation a child of the
failure event, the trace timeline mirroring the workflow exactly.
Persistence on top of the same generator gives you a state-machine
worker that survives restarts. With retries layered in, the saga
self-heals through transient failures before it ever has to
compensate. Each layer adds code; none changes the shape of the
core generator above.
That is the forward motion: a small typed kernel that does not rot
when you add the operational layers production needs.
If this was useful
The saga above is a worked example of the kind of typed-error
discipline TypeScript in Production walks through. The book
covers tsconfig, build orchestration, monorepos, and library
authoring across Node, Bun, and Deno.
The full TypeScript Library, five books:
- TypeScript Essentials — daily-driver TS across Node, Bun, Deno, and the browser: Amazon
- The TypeScript Type System — generics, mapped/conditional types, template literals, branded types: Amazon
- Kotlin and Java to TypeScript — bridge for JVM developers: Amazon
- PHP to TypeScript — bridge for modern PHP 8+ developers: Amazon
- TypeScript in Production — tsconfig, build, monorepos, library authoring: Amazon
Books 1 and 2 are the core path. 3 and 4 substitute for readers coming from JVM or PHP. 5 is for shipping TS at work.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)