| 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.
The dual-form contract
Every transform in @onrails/result and @onrails/maybe accepts two call shapes:
map(result, fn);
map(fn)(result);
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:
match(result, onOk, onErr);
match(onOk, onErr)(result);
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
pipe(value, f, g, h) === h(g(f(value)));
flow(f, g, h)(value) === h(g(f(value)));
pipe(x, ...fns) === flow(...fns)(x);
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:
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
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 })),
);
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:
pipe(
validated,
asyncAfter((v) => tryAsync(persist(v))),
);
And pipe still slots arbitrary (prev) => next lambdas when a step is genuinely one-off and has no curried form:
pipe(
parseConfig(raw),
map((cfg) => cfg.users),
(r) => r.map((users) => users.slice(0, 10)),
);
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:
import { flow } from "@onrails/result/pipe";
import { flatMap, map, type Result } from "@onrails/result";
const parseAndValidate = flow(parseJson, flatMap(validateSchema));
const enrichAndPersist = flow(addTimestamp, persist);
const ingest = flow(parseAndValidate, flatMap(enrichAndPersist));
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:
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" });
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:
flow(fetchSync, recover(handler));
flow(recover(handler), fetchSync);
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:
const fetchWithBackoff = flow(
fetchSync,
recover((e) => fetchSync(url)),
);
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).
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.
const ingest = (raw: string) =>
pipe(
raw,
parseUser,
(validated) =>
tryGen(() => {
const user = $(validated);
const enriched = $(enrichWithAcl(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.
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 });
}
return ok(post);
});
c) Conditional unwrap — $ inside a branch. Only the taken branch unwraps; the error union still accumulates both sides.
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.
const applyAll = (state: State, steps: readonly Step[]) =>
tryGen(() => {
let acc = state;
for (const step of steps) {
acc = $(applyStep(acc, step));
}
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 — 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 — workflow-builder layer above this one. Use when named context wins over positional plumbing.