Tagged Result / ResultAsync for railway-oriented TypeScript. Pure tagged unions, neverthrow-shaped compat shim, FL-friendly.
bun add @onrails/result@file:../onrails/packages/result
import {
asyncAfter,
err,
flatMapResult,
fromAsync,
mapResult,
match,
ok,
trySync,
} from "@onrails/result";
const parse = trySync(
(raw: string) => JSON.parse(raw),
(e) => ({ kind: "parse" as const, message: String(e) }),
);
const pipeline = flatMapResult(parse('{"v":1}'), (data) => ok(data.v));
Long chains: fluent() from @onrails/result/fluent or flatMapResult (not curried flatMap) for TS inference.
For worked examples of multi-step pipelines, parser builders, validator ladders, and parallel sub-workflows see RECIPES.md.
| 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.* (fluent) or railway(...) (functional, reusable steps) |
| Linear sync with early-return feel | tryGen + $ from @onrails/result/try-gen |
| Independent validations, accumulated failures | validateAll / validateTuple from @onrails/result/validation |
| 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.
Use fromResult when a sync Result needs to enter a ResultAsync pipeline without widening the error channel:
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:
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:
const body = tryAsync(fetch(url).then((res) => res.text()));
const status = tryAsync(fetch(url), (error) => ({
kind: "network" as const,
message: String(error),
}));
Prefer tagged objects, not bare extends Error classes — TS collapses structurally identical errors (#652).
type BotError =
| { kind: "not_found"; id: string }
| { kind: "network"; message: string };
Helpers: @onrails/result/extra — hasKind, mapErrKind, declareErrors, UnionErrors, AccumulateErrors.
fromAsyncLift async handlers that return Result without leaking Promise<Result<…>>:
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);
ResultAsyncResultAsync 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.
const r = await getItemAsync();
if (isOk(r)) console.log(r.value.id);
else console.error(r.error);
matchResult is an alias for match for files that also import match from ts-pattern:
import { matchResult } from "@onrails/result";
import { match } from "ts-pattern";
unwrapOk and unwrapErr are test/assertion helpers. Prefer match, isOk, or isErr in production control flow.
import { unwrapOk } from "@onrails/result";
expect(unwrapOk(parseConfig(raw))).toEqual(expected);
import { toToolResponseAsync, unwrapFetchResultAsync } from "@onrails/result/mcp";
const ra = unwrapFetchResultAsync(
client.GET("/tokens/{id}"),
({ error, response }) => new PrintrApiError(response.status, detail),
);
return toToolResponseAsync(ra);
tryGen — sync ?For short linear sync code:
import { $, ok, tryGen } from "@onrails/result";
const out = tryGen(() => {
const a = $(parseA());
const b = $(parseB());
return ok(a + b);
});
Use sequenceTupleAsync (or parallelTupleAsync when branches should overlap) when combining heterogeneous async results and destructuring the result:
import { sequenceTupleAsync } from "@onrails/result";
const combined = sequenceTupleAsync([
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 workflowsUse Railway from @onrails/result/railway when a service workflow has several named sync/async steps and would otherwise need manual context-carrying objects:
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.
railway(...) — reusable workflow stepsUse lowercase railway(...) when the steps should be named once and reused across workflows:
import {
deriveNamed,
fromPromiseNamed,
parallelNamed,
parseWith,
railway,
requireNamed,
select,
} from "@onrails/result/railway";
const parseProfileId = parseWith(ProfileIdSchema, toError).as("profileId");
const loadProfileRow = fromPromiseNamed(
"row",
({ profileId }) => loadProfileRowById(profileId),
toError,
);
const requireProfile = requireNamed("profile", "row", ({ profileId }) =>
new Error(`Profile not found: ${profileId}`),
);
const loadSummaryInputs = parallelNamed({
recentArtifacts: ({ profile }) => loadRecentArtifacts(profile.id),
jobMetrics: ({ profile }) => loadJobMetrics(profile.id),
});
const summary = railway(
id,
parseProfileId,
loadProfileRow,
requireProfile,
deriveNamed("normalized", ({ profile }) => normalizeProfile(profile)),
loadSummaryInputs,
select(({ normalized, recentArtifacts, jobMetrics }) =>
toProfileSummary({ normalized, recentArtifacts, jobMetrics }),
),
);
railway(input, ...steps) starts from { input }. parseWith(...).as(key) is the usual first step for raw input. The final output is still mode-aware: sync-only steps return Result, while async steps return ResultAsync.
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);
@onrails/eslint-plugin — warns on Promise<Result<…>> and _unsafeUnwrap*.
See @onrails/codemod for the automated codemod, and the Compat surface notes below.
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.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.@onrails/result and @onrails/result/fluent.| Path | Contents |
|---|---|
@onrails/result |
Core + interop exports |
@onrails/result/fluent |
fluent(), fluentAsync() |
@onrails/result/extra |
Error-type utilities |
@onrails/result/interop |
fromAsync, fromResult, asyncAfter |
@onrails/result/mcp |
MCP / openapi-fetch helpers |
@onrails/result/pipe |
flow (variadic point-free composition) |
@onrails/result/railway |
Railway, railway, named workflow helpers |
@onrails/result/try-gen |
tryGen, yieldResult, $ |
@onrails/result/compat/neverthrow |
Migration shim |
See DESIGN.md.