UNPKG

@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
# 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.