UNPKG

@onrails/result

Version:

Tagged Result / ResultAsync for railway-oriented TypeScript — pure tagged unions, neverthrow-shaped compat shim, FL-friendly

269 lines (193 loc) 10 kB
# @onrails/result Tagged `Result` / `ResultAsync` for railway-oriented TypeScript. Pure tagged unions, neverthrow-shaped compat shim, FL-friendly. ## Install ```bash bun add @onrails/result ``` ## Quick start (value-first — best inference) ```ts import { err, flatMap, match, ok, trySync } from "@onrails/result"; const parse = trySync( (raw: string) => JSON.parse(raw), (e) => ({ kind: "parse" as const, message: String(e) }), ); const pipeline = flatMap(parse('{"v":1}'), (data) => ok(data.v)); ``` Every transform is dual-form: data-first `flatMap(r, fn)` (best inference) or curried `flatMap(fn)(r)` for `pipe`/`flow`. Long chains: `fluent()` from `@onrails/result/fluent`. For worked examples of multi-step pipelines, parser builders, validator ladders, and parallel sub-workflows see [RECIPES.md](./RECIPES.md). ## When to use what | Shape | Reach for | | ---------------------------------- | -------------------------------------------------------------------- | | One or two sync steps | `flatMapResult`, `mapResult`, `match` | | One or two async steps | `ResultAsync.flatMap`, `asyncAfter` | | Long sync chain, value-first | `pipe(r, map(...), flatMap(...), ...)` | | Long sync chain, dot-style preferred | `fluent(r)` from `@onrails/result/fluent` | | Reusable composed function | `flow(...)` from `@onrails/result/pipe` | | Several named sync/async steps | `Railway.*` builder from `@onrails/result/railway` | | Linear sync with early-return feel | `tryGen` + `$` from `@onrails/result/try-gen` | | Independent validations, accumulated failures | `validateAll` / `validateTuple` from `@onrails/result` | | Syncasync lift, keep error type | `fromResult`, `asyncAfter` (do **not** use `fromAsync` here) | | `Promise<Result<…>>` boundary lift | `fromAsync` / `tryAsync` | Rule of thumb: pick the smallest tool that removes nesting. Reach for `Railway` only when named context replaces positional tuple plumbing. ## Syncasync boundaries Use `fromResult` when a sync `Result` needs to enter a `ResultAsync` pipeline without widening the error channel: ```ts import { fromResult, ok, type Result } from "@onrails/result"; const parsed: Result<number, "parse"> = ok(1); const asyncParsed = fromResult(parsed); // ResultAsync<number, "parse"> — no UnexpectedError widening ``` Use `asyncAfter` for the common "validate synchronously, then run async IO" shape: ```ts import { asyncAfter, tryAsync, trySync } from "@onrails/result"; return asyncAfter( trySync(() => ArtifactSchema.parse(artifact), toError)(), (validated) => tryAsync( getDb() .insert(artifacts) .values(validated) .then(() => undefined), ), ); ``` Use `tryAsync` for Promise boundaries with default `Error` normalization, or pass a custom rejection mapper: ```ts const body = tryAsync(fetch(url).then((res) => res.text())); const status = tryAsync(fetch(url), (error) => ({ kind: "network" as const, message: String(error), })); ``` ## Tagged error style Prefer **tagged objects**, not bare `extends Error` classes — TS collapses structurally identical errors ([#652](https://github.com/supermacro/neverthrow/issues/652)). ```ts type BotError = | { kind: "not_found"; id: string } | { kind: "network"; message: string }; ``` Helpers: `@onrails/result/extra` — `hasKind`, `mapErrKind`, `declareErrors`, `UnionErrors`, `AccumulateErrors`. ## Async interop — `fromAsync` Lift `async` handlers that return `Result` without leaking `Promise<Result<…>>`: ```ts import { fromAsync, ok, err } from "@onrails/result"; async function getItem(): Promise<Result<{ id: string }, HttpError>> { if (!user) return err({ kind: "unauthorized" }); return ok({ id: "x" }); } // Public API: ResultAsync only export const getItemAsync = fromAsync(getItem); ``` ## Awaitable `ResultAsync` `ResultAsync` is thenable — `await ra` resolves to a bare tagged-union `Result<T, E>`. Narrow with `isOk(r)` / `isErr(r)` (type predicates) to read `.value` / `.error`. ```ts const r = await getItemAsync(); if (isOk(r)) console.log(r.value.id); else console.error(r.error); ``` ## Match and unwrap helpers `match` is the canonical positional fold. If a file imports `match` from `@onrails/pattern` or `ts-pattern`, resolve the collision by using namespace imports: ```ts import * as R from "@onrails/result"; import { match } from "ts-pattern"; R.match(result, onOk, onErr); ``` `unwrapOk` and `unwrapErr` are test/assertion helpers. Prefer `match`, `isOk`, or `isErr` in production control flow. ```ts import { unwrapOk } from "@onrails/result"; expect(unwrapOk(parseConfig(raw))).toEqual(expected); ``` ## `tryGen` — sync `?` For short linear sync code: ```ts import { $, ok, tryGen } from "@onrails/result"; const out = tryGen(() => { const a = $(parseA()); const b = $(parseB()); return ok(a + b); }); ``` Use `ResultAsync.combineTuple` (or `ResultAsync.combineTupleParallel` when branches should overlap) when combining heterogeneous async results and destructuring the result: ```ts import { ResultAsync } from "@onrails/result"; const combined = ResultAsync.combineTuple([ loadSettings(), loadModelCatalog(), ] as const); const dto = combined.map(([settings, catalog]) => buildDto(settings, catalog), ); ``` When TS only infers the first error in a generator-style flow, use `declareErrors<E1 | E2>()` from `/extra`. ## `Railway` — named service workflows Use `Railway` from `@onrails/result/railway` when a service workflow has several named sync/async steps and would otherwise need manual context-carrying objects: ```ts import { Railway } from "@onrails/result/railway"; const summary = Railway.fromSync("profileId", () => ProfileIdSchema.parse(id), toError) .fromPromise("row", ({ profileId }) => loadProfileRow(profileId), toError) .require("profile", "row", ({ profileId }) => new Error(`Profile not found: ${profileId}`)) .derive("normalized", ({ profile }) => normalizeProfile(profile)) .fromResult("stats", ({ normalized }) => enrichProfileStats(normalized)) .parallel({ recentArtifacts: ({ normalized }) => loadRecentArtifacts(normalized.id), jobMetrics: ({ normalized }) => loadJobMetrics(normalized.id), }) .select(({ normalized, stats, recentArtifacts, jobMetrics }) => toProfileSummary({ normalized, stats, recentArtifacts, jobMetrics }), ); ``` Sync-only workflows return `Result<T, E>`. The first `fromPromise`, `fromAsync`, or `parallel` step upgrades the output to `ResultAsync<T, E>`. Use lower-level helpers (`asyncAfter`, `fromResult`, `flatMapResult`) for one or two steps where a builder would add ceremony. To share steps across workflows, extract plain functions of the context and plug them in via `.fromResult` / `.fromAsync`: ```ts const loadProfileRow = ({ profileId }: { profileId: string }) => tryAsync(loadProfileRowById(profileId), toError); const summary = Railway.fromSync("profileId", () => ProfileIdSchema.parse(id), toError) .fromAsync("row", loadProfileRow) .require("profile", "row", ({ profileId }) => new Error(`Profile not found: ${profileId}`)) .select(({ profile }) => toProfileSummary(profile)); ``` ## Pipe ```ts import { pipe } from "@onrails/result"; import { flow } from "@onrails/result/pipe"; // Value-first variadic pipe — threads a starting value through unary steps. const name = pipe( parseConfig(raw), map((cfg) => cfg.user), flatMap((u) => (u.name ? ok(u.name) : err({ kind: "missing" }))), recover((e) => (e.kind === "missing" ? ok("anon") : err(e))), tap((n) => log(n)), ); // Variadic point-free composition — define a reusable pipeline. const parseUserName = flow( (raw: string) => parseConfig(raw), map((cfg) => cfg.user), flatMap((u) => (u.name ? ok(u.name) : err({ kind: "missing" }))), ); parseUserName(raw); ``` ## ESLint `@onrails/eslint-plugin` — warns on `Promise<Result<…>>` and `_unsafeUnwrap*`. ## Migration from neverthrow See [@onrails/codemod](../codemod/README.md) for the automated codemod, and the **Compat surface** notes below. ### Compat surface ```ts import { ResultAsync, Result, ok, err, okAsync, errAsync } from "@onrails/result/compat/neverthrow"; ``` - `Result` / `ResultAsync` are class-shaped (`CompatResult` / `CompatResultAsync`). - `await ra` resolves to a `CompatResult<T, E>` (thenable), so `.isOk()`, `.value`, `.error`, `.match()`, `.unwrapOr()` all work without an extra `.resolve()` call. - `andThen` / `chain` / `flatMap` / `orElse` accept any of `CompatResultAsync` / `ResultAsync` / `CompatResult` / tagged `Result` returns and union the error type. - Supported: `andThen`, `asyncAndThen`, `chain`, `flatMap`, `flatMapResult`, `andThenResult`, `map`, `mapErr`, `orElse`, `match`, `unwrapOr`, `isOk`, `isErr`, `andTee`, `orTee`, `Result.combine`, `Result.fromThrowable`, `ResultAsync.combine`, `ResultAsync.fromPromise`, `ResultAsync.fromSafePromise`, `ResultAsync.fromThrowable`, `_unsafeUnwrap` / `_unsafeUnwrapErr`. - Treat the compat surface as a migration step, not the destination — once a package migrates, switch its imports to `@onrails/result` and `@onrails/result/fluent`. ## Subpaths | Path | Contents | |------|----------| | `@onrails/result` | Core + interop exports | | `@onrails/result/fluent` | `fluent()` | | `@onrails/result/extra` | Error-type utilities | | `@onrails/result/pipe` | `flow` (variadic point-free composition) | | `@onrails/result/railway` | `Railway` named-context workflow builder | | `@onrails/result/try-gen` | `tryGen`, `yieldResult`, `$` | | `@onrails/result/compat/neverthrow` | Migration shim | See [DESIGN.md](./DESIGN.md).