UNPKG

@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
--- 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 | **stopuse `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 waysuse `tryGen` or escalate to `Railway` - a single step needs three different references to the same intermediate valuename 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.