@onrails/result
Version:
Tagged Result / ResultAsync for railway-oriented TypeScript — pure tagged unions, neverthrow-shaped compat shim, FL-friendly
478 lines (349 loc) • 17.4 kB
Markdown
# Recipes — point-free composition
A cookbook of functional composition patterns built on `@onrails/result` + `@onrails/maybe`. Every transform in the core API is **dual-form** — call it data-first for one-shots, or data-last (curried) for point-free pipelines:
```ts
map(result, fn); // data-first — best inference for one-shot calls
map(fn)(result); // curried, data-last — feeds `pipe(...)` and `flow(...)`
```
`pipe(value, ...fns)` is variadic value-first composition. `flow(...fns)` is the same thing for **defining** a reusable composed function. Both consume the curried (data-last) overload.
See [README.md](./README.md) for the API reference and decision tree.
## Composition Guidelines (The Four Tiers)
To keep codebases readable and consistent, follow the four-tier decision guideline when choosing how to compose operations. Use the simplest pattern that fits your workflow:
| Tier | Use Case | Recommended Pattern |
| ---- | -------- | ------------------- |
| **1** | 1–2 steps, linear | direct data-first calls or method chains |
| **2** | 3+ steps, linear | `pipe` or `flow` |
| **3** | branchy, value reused | `tryGen` escape hatch |
| **4** | 4+ named steps, mixed IO | `Railway` builder |
`/fluent` is documented as app-edge sugar only — never in library or service internals.
### Tier 1: 1–2 steps, linear (Direct data-first call or method chain)
```ts
import { map } from "@onrails/result";
const normalized = map(rawResult, normalize);
```
### Tier 2: 3+ steps, linear (pipe / flow)
```ts
import { flow, map, flatMap, recover } from "@onrails/result";
const process = flow(
map(normalize),
flatMap(validate),
recover(fallback)
);
```
### Tier 3: Branchy, value reused (tryGen)
```ts
import { tryGen, $ } from "@onrails/result/try-gen";
import { ok, err } from "@onrails/result";
const stepResult = tryGen(() => {
const user = $(authenticate(req));
const post = $(fetchPost(postId));
if (post.authorId !== user.id && !user.isAdmin) {
return err({ kind: "unauthorized" as const });
}
return ok(post);
});
```
### Tier 4: 4+ named steps, mixed IO (Railway context builder)
```ts
import { Railway } from "@onrails/result/railway";
const workflow = Railway
.fromSync("id", () => IdSchema.parse(raw), toError)
.fromPromise("row", ({ id }) => db.profiles.find(id), toError)
.require("profile", "row", ({ id }) => ({ kind: "not_found" as const, id }))
.derive("normalized", ({ profile }) => normalizeProfile(profile))
.select(({ normalized }) => normalized);
```
## 1. Reusable parser builder via `flow`
Define a parser factory once, instantiate per schema.
```ts
import { flow } from "@onrails/result/pipe";
import { flatMap, trySync, type Result } from "@onrails/result";
type ParseError = { kind: "parse"; message: string };
type SchemaError = { kind: "schema"; field: string };
const parseJsonWith = <T>(schema: { parse: (x: unknown) => T }) =>
flow(
trySync(JSON.parse, (e): ParseError => ({ kind: "parse", message: String(e) })),
flatMap(trySync(schema.parse, (e): SchemaError => ({ kind: "schema", field: String(e) }))),
);
const parseUser = parseJsonWith(UserSchema);
const parseOrder = parseJsonWith(OrderSchema);
// (raw: string) => Result<User, ParseError | SchemaError>
```
Why point-free: the parser's *input* never appears in the definition. Reuse the factory across every schema without binding the data shape.
## 2. ETL pipeline: parse → validate → enrich → persist
A typical service-layer flow chains sync validation with async IO.
```ts
import { ok, err, pipe, flatMap, mapErr, tap } from "@onrails/result";
import { asyncAfter, tryAsync } from "@onrails/result";
type DbError = { kind: "db"; cause: unknown };
const saveOrder = (rawJson: string) =>
pipe(
rawJson,
parseUser, // Result<User, ParseError | SchemaError>
flatMap((u) => (u.active ? ok(u) : err({ kind: "inactive" as const }))),
mapErr((e) => ({ kind: "input" as const, cause: e })),
tap((u) => log.info({ msg: "validated", userId: u.id })),
asyncAfter((u) =>
tryAsync(
db.orders.insert({ userId: u.id, items: u.cart }),
(e): DbError => ({ kind: "db", cause: e }),
),
),
);
// ResultAsync<OrderRow, { kind: "input"; cause: ... } | DbError>
```
`asyncAfter` is dual-form like the rest of the core API: the data-last call `asyncAfter((u) => ...)` is the sync→async bridge that drops straight into `pipe`. The final step stays point-free — `pipe` feeds the upstream `Result` into the curried bridge automatically.
## 3. Tagged-error unification across sources
Three call sites return three error shapes. `mapErr` data-last unifies them into one app-level union.
```ts
import { pipe, flatMap, mapErr } from "@onrails/result";
type AppError =
| { kind: "auth"; reason: string }
| { kind: "http"; status: number }
| { kind: "decode"; field: string };
const loadDashboard = (token: string) =>
pipe(
token,
verifyToken, // Result<Session, "expired" | "bad_sig">
mapErr((reason): AppError => ({ kind: "auth", reason })),
flatMap(fetchProfileSync), // Result<Profile, { status: number }>
mapErr((e): AppError => ("status" in e ? { kind: "http", status: e.status } : e)),
flatMap(decodeProfile), // Result<DTO, { field: string }>
mapErr((e): AppError => ("field" in e ? { kind: "decode", field: e.field } : e)),
);
// Result<DTO, AppError>
```
Every `mapErr` is a data-last partial — drops into `pipe` without a wrapping `(result)`.
## 4. Maybe → Result railway crossing
When "missing" becomes a domain failure at a specific boundary.
```ts
import { flow } from "@onrails/result/pipe";
import { map } from "@onrails/result";
import { fromNullable } from "@onrails/maybe";
import { toResult } from "@onrails/maybe/interop";
type RowMissing = { kind: "not_found"; id: string };
const requireRow = <T>(loader: (id: string) => T | null | undefined) =>
flow(
(id: string) =>
toResult(
fromNullable(loader(id)),
(): RowMissing => ({ kind: "not_found", id }),
),
map((row: T) => ({ ...row, loadedAt: Date.now() })),
);
const requireUser = requireRow(userCache.get);
const requireOrder = requireRow(orderCache.get);
// (id: string) => Result<User & { loadedAt: number }, RowMissing>
```
`Maybe` models expected absence; `toResult` is the explicit boundary where absence converts to a typed domain failure.
## 5. Parallel sub-workflows + downstream merge
Three independent async loads overlap in wall-clock time; downstream `.map` reshapes the tuple.
```ts
import { pipe, ResultAsync } from "@onrails/result";
type ProfileError = { kind: "profile"; cause: unknown };
type MetricsError = { kind: "metrics"; cause: unknown };
const buildSummary = (userId: string) =>
pipe(
ResultAsync.combineTupleParallel([
loadProfile(userId), // ResultAsync<Profile, ProfileError>
loadRecentMetrics(userId), // ResultAsync<Metrics, MetricsError>
loadFeatureFlags(userId), // ResultAsync<Flags, never>
] as const),
(combined) =>
combined.map(([profile, metrics, flags]) => ({
userId,
name: profile.name,
score: metrics.score,
features: flags.enabled,
})),
);
// ResultAsync<Summary, ProfileError | MetricsError>
```
`ResultAsync.combineTupleParallel` preserves tuple positions, so destructuring stays type-safe. Use static `ResultAsync.combineTuple` if branches must run left-to-right.
## 6. Reusable validator ladder via `flow` + `recover`
Compose validators data-last; `recover` re-tags or rescues specific error kinds.
```ts
import { flow } from "@onrails/result/pipe";
import { flatMap, recover, ok, err } from "@onrails/result";
type LengthError = { kind: "len"; min: number };
type CharsError = { kind: "chars"; bad: string };
type TooShortError = { kind: "too_short"; min: number };
const requireMin = (min: number) => (s: string) =>
s.length >= min ? ok(s) : err({ kind: "len" as const, min });
const requireAscii = (s: string) =>
/^[\x20-\x7e]*$/.test(s) ? ok(s) : err({ kind: "chars" as const, bad: s });
const validateUsername = flow(
(raw: string) => ok(raw.trim()),
flatMap(requireMin(3)),
flatMap(requireAscii),
recover(
(e: LengthError | CharsError): Result<string, TooShortError | CharsError> =>
e.kind === "len" ? err({ kind: "too_short" as const, min: e.min }) : err(e),
),
);
// (raw: string) => Result<string, TooShortError | CharsError>
```
`requireMin(3)` is a curried factory — `flow` strings it into the pipeline alongside `requireAscii`. None of the inner steps mention the value.
## 7. `tryGen` escape hatch for branchy logic
When a single value gets consumed multiple times in branchy logic, point-free flatMap chains get awkward. Drop into `tryGen` and rejoin the pipe.
```ts
import { ok, pipe, mapErr } from "@onrails/result";
import { tryGen, $ } from "@onrails/result/try-gen";
const ingest = (raw: string) =>
pipe(
raw,
parseUser,
(validated) =>
tryGen(() => {
const user = $(validated); // unwrap or short-circuit
const enriched = $(enrichWithAcl(user));
const persisted = $(persistSync(enriched));
return ok({ user: persisted, at: Date.now() });
}),
mapErr((e) => ({ kind: "ingest" as const, cause: e })),
);
```
`tryGen` is a sync island. Use it when:
- The same intermediate value is referenced three or more times.
- Conditional branching makes the `flatMap` chain feel inverted.
- You want `?`-style early returns without method-chaining.
Use `pipe` everywhere else — it composes better and reads top-to-bottom.
## 8. Conditional recovery with `recover` + targeted re-throw
`recover` is the error-track bind. Recover only the kinds you can handle; pass the rest through.
```ts
import { pipe, flatMap, recover, ok, err } from "@onrails/result";
type NetworkError = { kind: "network"; retryable: boolean };
type RateLimit = { kind: "rate_limit"; retryAfter: number };
type Fatal = { kind: "fatal"; message: string };
const fetchWithBackoff = (url: string) =>
pipe(
url,
fetchSync, // Result<Body, NetworkError | RateLimit | Fatal>
recover((e: NetworkError | RateLimit | Fatal) =>
// only retryable network errors do work; everything else bubbles up
e.kind === "network" && e.retryable ? fetchSync(url) : err(e),
),
);
// Result<Body, NetworkError | RateLimit | Fatal>
```
## 9. Pure error unification (no-lookback recover)
When `recover` only inspects the error, the whole pipeline collapses into one `flow`. The resulting function's input type infers from the first step.
```ts
import { flow } from "@onrails/result/pipe";
import { recover, err, ok, type Result } from "@onrails/result";
type NetworkError = { kind: "network"; retryable: boolean };
type Fatal = { kind: "fatal"; message: string };
declare const fetchSync: (url: string) => Result<Body, NetworkError | Fatal>;
declare const emptyBody: Body;
const fetchOrEmpty = flow(
fetchSync,
recover((e: NetworkError | Fatal) =>
e.kind === "fatal" ? err(e) : ok(emptyBody),
),
);
// (url: string) => Result<Body, Fatal>
```
The error union narrows automatically — `NetworkError` is absorbed into the Ok track, only `Fatal` remains.
## 10. Strategy-parametrised flows (closure ladder)
When `recover` needs configuration, take it on the *outer* factory. The inner `flow` still composes point-free over the data.
```ts
import { flow } from "@onrails/result/pipe";
import { map, recover, ok, err, type Result } from "@onrails/result";
type FetchError = { kind: "network" } | { kind: "fatal"; message: string };
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" });
const fetchOrThrow = fetchWith({ rethrow: () => true });
// both: (url: string) => Result<number, FetchError>
```
Outer closure captures things that don't change per-call (config); the inner `flow` stays point-free over the per-call data (`url`). Best of both.
## 11. Composing flows
`flow` is associative — `flow(flow(a, b), c) === flow(a, b, c)`. Break long pipelines into named mini-pipelines and compose them.
```ts
import { flow } from "@onrails/result/pipe";
import { flatMap, map, type Result } from "@onrails/result";
type Saved = { id: string; at: number };
type ParseError = { kind: "parse"; message: string };
type SchemaError = { kind: "schema"; field: string };
type DbError = { kind: "db"; cause: unknown };
declare const parseJson: (raw: string) => Result<unknown, ParseError>;
declare const validateSchema: (x: unknown) => Result<Validated, SchemaError>;
declare const addTimestamp: (v: Validated) => Validated & { ts: number };
declare const persist: (v: Validated & { ts: number }) => Result<Saved, DbError>;
// 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);
const ingest = flow(parseAndValidate, flatMap(enrichAndPersist));
// (raw: string) => Result<Saved, ParseError | SchemaError | DbError>
```
Each sub-flow has a clear purpose; the top-level `ingest` reads as a sentence. Errors union automatically through `flatMap`'s `E | F` rule.
## 12. Async pipelines via `ResultAsync` composition
When composing functions that return `ResultAsync` values, `flow` chains them by wrapping the async transforms in arrow functions. The resulting pipeline remains point-free over the input.
```ts
import { flow } from "@onrails/result/pipe";
import { type ResultAsync } from "@onrails/result";
type Profile = { id: string; name: string };
type Metrics = { score: number };
type Summary = { name: string; score: number };
declare const fetchProfile: (id: string) => ResultAsync<Profile, Error>;
declare const fetchMetrics: (p: Profile) => ResultAsync<Metrics, Error>;
declare const formatSummary: (p: Profile, m: Metrics) => Summary;
const loadSummary = flow(
fetchProfile,
(ra) => ra.flatMap((profile) =>
fetchMetrics(profile).map((metrics) => formatSummary(profile, metrics))
),
);
// (id: string) => ResultAsync<Summary, Error>
```
The middle step can't go point-free: `profile` is reused across both the `flatMap` and the inner `map`. When a value is referenced by two or more async steps, this nested-closure shape is the cost of staying in `flow`. If it gets deeper, drop to the [`tryGen` escape hatch](#7-trygen-escape-hatch-for-branchy-logic) (Tier 3) and rejoin the pipe.
## 13. Railway workflow pipelines (named-context builder)
Use the `Railway` builder from `@onrails/result/railway` to build a multi-step async pipeline with named context fields. If any step is async, the entire pipeline resolves to a `ResultAsync`.
```ts
import { Railway } from "@onrails/result/railway";
import { type ResultAsync } from "@onrails/result";
type Dashboard = { title: string };
declare const IdSchema: { parse: (x: unknown) => string };
declare const fetchProfile: (id: string) => Promise<Profile>;
declare const toError: (e: unknown) => Error;
const loadDashboard = (rawId: unknown): ResultAsync<Dashboard, Error> =>
Railway.fromSync("id", () => IdSchema.parse(rawId), toError)
.fromPromise("profile", ({ id }) => fetchProfile(id), toError)
.derive("title", ({ profile }) => profile.name.toUpperCase())
.select(({ title }) => ({ title }));
```
Every step reads name-first (`fromSync("id", …)`, `fromPromise("profile", …)`, `derive("title", …)`) so the field names line up at the left edge, and each step's callback sees the fully-typed context accumulated so far — no manual context annotations needed.
## When NOT to go point-free
Pipelines should read top-to-bottom and each step should do 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 `Railway` for named context.
- 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). Either annotate the carrier type, or revert that step to a named one-shot.
The dual-form lets you mix both — start point-free, drop to a named step when clarity beats compression.