@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
Markdown
# @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` |
| Sync → async 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.
## Sync → async 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).