Skip to content

Sequence dependent steps with do-notation

How-to. When several steps each depend on the values of the ones before, Do / bind / let flatten nested flatMap callbacks into a linear chain that accumulates a named scope — with the same defect guarantees as every other combinator.

Build a scope with Do · bind · let

Start a chain with Do() (an empty object scope), then grow it:

  • bind(name, f)f receives the scope so far and returns a Result. On Ok, its value is added to the scope under name; on Err/Defect the chain short-circuits. Error types union across binds.
  • let(name, f) — the pure-value counterpart: f returns a plain value (not a Result), added under name.
ts
import { Do } from "unthrown";

const view = Do()
  .bind("user", () => findUser(id)) // Result<User, NotFound>
  .bind("org", ({ user }) => findOrg(user.orgId)) // Result<Org, NotFound>
  .let("label", ({ user, org }) => `${user.name} @ ${org.name}`)
  .map(({ user, org, label }) => render(user, org, label));
// Result<View, NotFound>

Each step's callback is typed with everything bound so far, and the final value is the accumulated object. The scope is readonly — you don't mutate it mid-chain. Do is capitalised because do is a reserved word.

It's just a Result

A do-chain is an ordinary Result at every step — bind/let are methods on the normal surface, so you can mix in map, flatMap, match, and the rest freely, and a thrown callback still becomes a Defect:

ts
import { Do, Ok, Err, P, TaggedError } from "unthrown";

class TooSmall extends TaggedError("TooSmall") {}
declare const input: number;

Do()
  .bind("n", () => (input >= 2 ? Ok(input) : Err(new TooSmall())))
  .let("doubled", ({ n }) => n * 2)
  .match({
    ok: ({ n, doubled }) => `${n} → ${doubled}`,
    errCases: (matcher) => matcher.with(P.tag("TooSmall"), () => "too small"),
    defect: (cause) => `bug: ${String(cause)}`,
  });

Go async

To sequence asynchronous steps, lift the chain with toAsync() (or start from DoAsync()). From there a bind may return a Result or an AsyncResult (never a raw Promise — see Qualify a boundary):

ts
import { Do, fromPromise, P, TaggedError } from "unthrown";

class UserNotFound extends TaggedError("UserNotFound") {}
declare class MissingRowError extends Error {}

const profile = await Do()
  .toAsync()
  .bind("user", () =>
    fromPromise(fetchUser(id), (c, defect) =>
      c instanceof MissingRowError ? new UserNotFound() : defect(c),
    ),
  )
  .bind("posts", ({ user }) =>
    fromPromise(fetchPosts(user.id), (c, defect) => defect(c)),
  )
  .let("count", ({ posts }) => posts.length)
  .match({
    ok: (s) => s,
    errCases: (matcher) => matcher.with(P.tag("UserNotFound"), () => null),
    defect: () => null,
  });

Because binds union their error types, adding a failable step also adds a case to E — and the errCases matcher at the end stops compiling until that new case is named. Enumerating the arms is what makes the chain self-auditing.

When a later step's failure must undo the earlier ones

Do goes forward. When the third step failing means the first two have to be taken back — a placement to cancel, a reservation to release — that is a saga, and @unthrown/saga is the shape:

ts
import { SagaAsync } from "@unthrown/saga";

const fulfilled = await SagaAsync()
  .step(
    () => place(order),
    () => cancelPlacement(order),
  )
  .step(
    () => reserveStock(order),
    () => releaseStock(order),
  )
  .step(() => arrangeShipping(order))
  .run();
// shipping failed → stock released, then placement cancelled, then the Err

Three things it decides for you, each a trap in the hand-written walk-back:

  • The undos run in reverse. Getting that backwards is silent, and the hand-written version has nothing to check it.
  • Nothing runs early. An AsyncResult starts on construction, so an undo built outside the failure branch runs whether or not it was needed — the hazard unthrown/no-async-result-race exists for. Every argument here is a thunk, so there is nothing to build early.
  • Compensation may not fail. undo answers unknown in the Ok channel and never in the Err one: the caller is already handling the failure that triggered it, and a second error channel would ask it to handle two. A defect inside an undo is different — it wins over the failure that triggered it, because a compensation that broke is the more urgent report, and every remaining undo still runs first.

The failure itself comes back unchanged, so a caller triages exactly what it would have without the saga. run takes no argument — it is a thunk, and there is nothing to hand it; the undo receives its own step's value, so it can take back precisely what that step created. Either may answer a plain Result as well as an AsyncResult, so a synchronous compensation needs no toAsync().

It is pure control flow — no timers, no clock, no randomness — so it replays deterministically inside a workflow sandbox.

It is a separate package, not a core export: it is a pattern built on the public surface — nothing in it reaches a channel unthrown does not already expose — so installing it is the opt-in.

sh
pnpm add @unthrown/saga

When to reach for named functions instead

If a chain grows long enough that Do feels heavy, that is usually a sign the steps deserve named functions composed with flatMap. unthrown deliberately ships no generator (gen / safeTry) do-notation — the reasoning is in Design decisions.

Where to go next

Released under the MIT License.