---
url: /unthrown/how-to/sequence-dependent-steps.md
---
# 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](./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`](https://www.npmjs.com/package/@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`](./lint-your-codebase) 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](../explanation/design-decisions#no-generator-do-notation-gen-safetry).

## Where to go next

* Combine *independent* (not dependent) results: [Combine parallel results](./combine-parallel-results).
* The combinators you can mix in: [Combinator reference](../reference/combinators).
