@onrails/result
Version:
Tagged Result / ResultAsync for railway-oriented TypeScript — pure tagged unions, neverthrow-shaped compat shim, FL-friendly
324 lines (233 loc) • 13.2 kB
Markdown
---
name: result-composition
description: Primitive composition patterns for `@onrails/result` and `@onrails/maybe` — dual-form transforms, variadic `pipe`, variadic `flow`, point-free pipelines, the closure ladder. Use when writing or refactoring TypeScript that composes `Result` / `Maybe` transforms, when nesting `flatMap` calls feels noisy, when a reusable composed function would be cleaner than a wrapped expression, or when a user asks about "pipe", "flow", "compose", "point-free", "data-last", "dual-form", "curried Result", "tacit", "Ramda-style", or "nested flatMap". Do NOT use for full named-step service workflows — see the `railway-do-notation` skill for the workflow-builder layer above this one.
---
# Primitive composition in `@onrails/result`
This skill covers how to compose `Result` / `Maybe` transforms at the primitive layer — before reaching for a workflow builder. The dual-form API + variadic `pipe` / `flow` give you Ramda-shape point-free composition with full TS inference.
For named multi-step service workflows (Drizzle ETL, parallel enrichment, required nullable fields), escalate to the **`railway-do-notation`** skill.
For worked examples of the patterns below, see [`packages/result/RECIPES.md`](../../RECIPES.md).
## The dual-form contract
Every transform in `@onrails/result` and `@onrails/maybe` accepts **two call shapes**:
```ts
map(result, fn); // data-first — best inference for one-shot inline calls
map(fn)(result); // data-last (curried) — feeds pipe(...) and flow(...)
```
Arity at the call site picks the overload. Same applies to `mapErr`, `bimap`, `flatMap`, `recover`, `tap`, `tapErr`, `match`, and the sync→async bridge `asyncAfter` (`asyncAfter(result, fn)` data-first, `asyncAfter(fn)` data-last).
`match` and `bimap` use 3-args data-first, 2-args curried:
```ts
match(result, onOk, onErr); // data-first
match(onOk, onErr)(result); // data-last
```
## Decision tree
Use the smallest tool that makes the code clear:
| Shape | Reach for |
|-------|-----------|
| One-shot inline transform | `map(r, fn)` — data-first; TS infers `T` from `r` |
| 3+ sequential steps from a starting value | `pipe(value, map(fn), flatMap(g), ...)` |
| Defining a reusable composed function | `flow(map(fn), flatMap(g), ...)` returning `(value) => Result<…>` |
| Method-chain in expression position | `r.map(fn).flatMap(g)` on `ResultAsync` (class method) |
| 4+ named domain steps, mixed sync/async, nullable DB rows | **stop — use `railway-do-notation` skill** |
## `pipe` vs `flow` — the only difference
```ts
pipe(value, f, g, h) === h(g(f(value))); // value applied immediately
flow(f, g, h)(value) === h(g(f(value))); // returns a reusable function
pipe(x, ...fns) === flow(...fns)(x); // formal identity
```
Both are **left-to-right** (like Ramda's `R.pipe`, fp-ts `flow`, Effect `flow`). Not right-to-left `compose`.
Use `pipe` when you have the starting value at the call site. Use `flow` when you want to define a function once and apply it later.
## The closure ladder
The decision between `flow`, `pipe`, and an outer HOF wrapper depends on **what each step needs to read**:
| Step needs to read… | Shape |
|---------------------|-------|
| Nothing from outside the carrier value | `flow(step1, step2, ...)` — pure point-free |
| Per-call configuration that doesn't change with the data | `(cfg) => flow(step1(cfg), step2(cfg), ...)` — outer HOF closes over config |
| The original entry value mid-pipeline (e.g. retry coordinate) | `(input) => pipe(input, step1, step2, ...)` — closure over input, use `pipe` |
Each row up adds one closure layer. Picking the right row keeps the code as point-free as the actual data flow allows — no more, no less.
## Layer 1 — Direct dual-form calls
For one or two transforms, just call the dual-form fn directly:
```ts
import { map, flatMap, ok, err } from "@onrails/result";
const trimmed = map(parsedConfig, (cfg) => cfg.name.trim());
const validated = flatMap(trimmed, (name) =>
name.length > 0 ? ok(name) : err({ kind: "empty" as const }),
);
```
This is already clear. Don't wrap two calls in a `pipe` just to be consistent.
## Layer 2 — Variadic `pipe`
Reach for `pipe(value, ...fns)` when:
- 3+ sequential steps starting from a known value
- the chain reads top-to-bottom with each line doing one obvious thing
- intermediate results don't need to be named
```ts
import { pipe, map, flatMap, recover, tap } from "@onrails/result";
const greeting = pipe(
parseConfig(raw),
map((cfg) => cfg.user),
flatMap((u) => (u.name ? ok(u.name) : err({ kind: "empty" as const }))),
recover((e) => (e.kind === "empty" ? ok("anon") : err(e))),
tap((name) => log.info({ msg: "resolved", name })),
);
// Result<string, ParseError>
```
`asyncAfter` is dual-form too — its data-last call is the sync→async bridge that drops straight into `pipe`, so the step stays point-free:
```ts
pipe(
validated,
asyncAfter((v) => tryAsync(persist(v))), // Result → ResultAsync, point-free
);
```
And `pipe` still slots arbitrary `(prev) => next` lambdas when a step is genuinely one-off and has no curried form:
```ts
pipe(
parseConfig(raw),
map((cfg) => cfg.users),
(r) => r.map((users) => users.slice(0, 10)), // ad-hoc reshape, no point-free gain
);
```
Don't fight the railway — reach for the data-last form when it exists; embed a plain lambda only when the step is truly bespoke.
## Layer 3 — Variadic `flow`
Use `flow(...fns)` to **define reusable composed functions** without mentioning the data:
```ts
import { flow } from "@onrails/result/pipe";
import { flatMap, map, type Result } from "@onrails/result";
// Result-track mini-pipeline — takes raw, returns Result.
const parseAndValidate = flow(parseJson, flatMap(validateSchema));
// Value-track mini-pipeline — takes Ok value, returns Result. Lifted by `flatMap`.
const enrichAndPersist = flow(addTimestamp, persist);
// flow is associative — flow(flow(a, b), c) === flow(a, b, c).
const ingest = flow(parseAndValidate, flatMap(enrichAndPersist));
// (raw: string) => Result<Saved, ParseError | SchemaError | DbError>
```
`flow` shines when the same composition is reused across call sites, or when naming the mini-pipeline (`parseAndValidate`) clarifies intent.
## Layer 4 — Strategy-parametrised flows (HOF + `flow`)
When a step needs configuration that doesn't change per-call, take it on an outer factory. Inner `flow` stays point-free over the data:
```ts
import { flow } from "@onrails/result/pipe";
import { map, recover, ok, err } from "@onrails/result";
type FetchConfig = {
readonly fallback?: Body;
readonly rethrow: (e: FetchError) => boolean;
};
const fetchWith = (cfg: FetchConfig) =>
flow(
fetchSync,
recover((e: FetchError) =>
cfg.rethrow(e) || !cfg.fallback ? err(e) : ok(cfg.fallback),
),
map((body: Body) => body.byteLength),
);
const fetchOrEmpty = fetchWith({ fallback: emptyBody, rethrow: (e) => e.kind === "fatal" });
// (url: string) => Result<number, FetchError>
```
The outer fn captures *what varies between definitions*; the inner `flow` captures *what varies per-call*. Same shape as recipe #10 in `RECIPES.md`.
## Anti-patterns
### 1. Compose direction confusion
`flow` is **left-to-right**. The first fn is the entry point, not the last:
```ts
// Right:
flow(fetchSync, recover(handler)); // (url) => recover(handler)(fetchSync(url))
// Wrong (Ramda compose habit):
flow(recover(handler), fetchSync); // recover called first with `url`, type-fails
```
If you want right-to-left `compose`, build it on top in one line — but in practice, just use `flow` and read top-to-bottom.
### 2. Closure-over-input in `flow`
`flow(...)` has no lexical access to the eventual call argument. If a step needs to reference the entry value (e.g. retry with the original URL inside `recover`), you must wrap `flow` in a function-of-input:
```ts
// Wrong — `url` is undefined in the recover closure:
const fetchWithBackoff = flow(
fetchSync,
recover((e) => fetchSync(url)), // `url` is not in scope
);
// Right — wrap so recover closes over the outer parameter:
const fetchWithBackoff = (url: string) =>
pipe(
url,
fetchSync,
recover((e) => fetchSync(url)),
);
```
Decide via the closure ladder above: any step that looks back at the original input forces row 3 (wrap in `(input) => pipe(input, ...)`).
### 3. Point-free where a step needs the same value three times
When one intermediate value is referenced multiple times in branchy logic, `flatMap` chains start carrying ambient state and reading inverted. Drop into `tryGen` — `$(result)` unwraps the Ok value or short-circuits the whole block with the first Err (Rust's `?` for sync code).
```ts
import { tryGen, $ } from "@onrails/result/try-gen";
```
**a) Linear unwrap — same value reused downstream.** The point-free version would thread `user` through every closure; `$` names it once.
```ts
const ingest = (raw: string) =>
pipe(
raw,
parseUser,
(validated) =>
tryGen(() => {
const user = $(validated); // unwrap or short-circuit
const enriched = $(enrichWithAcl(user)); // reads user
const persisted = $(persistSync(enriched));
return ok({ user: persisted, at: Date.now() });
}),
mapErr((e) => ({ kind: "ingest" as const, cause: e })),
);
```
**b) Branch on an unwrapped value — early `return err(...)`.** This is the case `flatMap` reads worst: a guard between two unwraps.
```ts
const authorize = (req: Request, postId: string) =>
tryGen(() => {
const user = $(authenticate(req));
const post = $(fetchPost(postId));
if (post.authorId !== user.id && !user.isAdmin) {
return err({ kind: "forbidden" as const }); // early exit, no nesting
}
return ok(post);
});
// Result<Post, AuthError | FetchError | { kind: "forbidden" }>
```
**c) Conditional unwrap — `$` inside a branch.** Only the taken branch unwraps; the error union still accumulates both sides.
```ts
const resolve = (raw: string, opts: { strict: boolean }) =>
tryGen(() => {
const cfg = $(parseConfig(raw));
const name = cfg.name ?? (opts.strict ? $(err({ kind: "missing" as const })) : "anon");
return ok({ ...cfg, name });
});
```
**d) Loop with `$` — accumulate or bail on first failure.** A `for` loop with `$` is far clearer than `combine` + manual reduce when each iteration depends on the last.
```ts
const applyAll = (state: State, steps: readonly Step[]) =>
tryGen(() => {
let acc = state;
for (const step of steps) {
acc = $(applyStep(acc, step)); // first Err aborts the loop + the block
}
return ok(acc);
});
```
`tryGen` is a sync island. Use it when:
- the same value is referenced 3+ times
- conditional branching makes the `flatMap` chain feel inverted
- you want `?`-style early returns without method-chaining
- a `Result`-returning loop body must bail on first failure
### 4. Inference noise mid-chain
If a `pipe` accumulates two or more `as const` casts between steps, TS is telling you the carrier type is fighting back. Either:
- annotate the carrier type explicitly with a typed intermediate variable, or
- revert that step to a named one-shot and continue the pipe after it.
Point-free should reduce noise, not add it.
## When to escalate
When you find yourself reaching for **any** of:
- named context carrying values forward across many steps
- mixed sync + async boundaries with `fromResult` / `asyncAfter` plumbing on every line
- nullable Drizzle rows that must become required values
- independent async branches that should run in parallel and merge by name
…**stop and switch to the `railway-do-notation` skill**. `Railway.*` is designed for that territory and will read better than any amount of `pipe`/`flow` plumbing.
## When NOT to go point-free
`flow` and `pipe` are tools, not goals. Pipelines should read top-to-bottom with each step doing one obvious thing. Reach for `pipe` when:
- 3+ sequential steps
- each step has a clear *what*, not a clever *how*
- errors compose cleanly via `mapErr` / `recover`
Avoid point-free when:
- the pipeline branches on the value mid-flight in non-obvious ways — use `tryGen` or escalate to `Railway`
- a single step needs three different references to the same intermediate value — name it with `flatMap((x) => { ... })` and stop pretending it's anonymous
- TypeScript inference gets noisy (multiple `as const` casts mid-chain) — annotate or revert
The dual-form API lets you mix both styles freely: start point-free, drop to a named step when clarity beats compression.
## See also
- [`packages/result/RECIPES.md`](../../RECIPES.md) — 13 worked recipes covering parser builders, ETL pipelines, strategy-parametrised flows, validator ladders, Maybe → Result crossings, parallel sub-workflows, `tryGen` escape hatches, async pipelines, and Railway workflow pipelines.
- [`railway-do-notation`](../railway-do-notation/SKILL.md) — workflow-builder layer above this one. Use when named context wins over positional plumbing.