@onrails/result
Version:
Tagged Result / ResultAsync for railway-oriented TypeScript — pure tagged unions, neverthrow-shaped compat shim, FL-friendly
550 lines (545 loc) • 16.7 kB
JavaScript
// src/internal/dual.ts
var dual = (arity, body) => {
const run = body;
const dispatcher = (...args) => args.length >= arity ? run(...args) : (self) => run(self, ...args);
return dispatcher;
};
// src/result.ts
var ok = (value) => ({
_tag: "Ok",
value
});
var err = (error) => ({
_tag: "Err",
error
});
var isOk = (result) => result._tag === "Ok";
var isErr = (result) => result._tag === "Err";
var map = dual(
2,
(result, fn) => isOk(result) ? ok(fn(result.value)) : err(result.error)
);
var mapErr = dual(
2,
(result, fn) => isErr(result) ? err(fn(result.error)) : ok(result.value)
);
var flatMap = dual(
2,
(result, fn) => isOk(result) ? fn(result.value) : err(result.error)
);
function trySync(fn, onThrow) {
return (...args) => {
try {
return ok(fn(...args));
} catch (error) {
return err(onThrow(error));
}
};
}
// src/types.ts
var UnexpectedError = class extends Error {
constructor(message, cause) {
super(message);
this.cause = cause;
this.name = "UnexpectedError";
}
cause;
};
// src/async.ts
var sequenceSettled = (settled) => {
const values = [];
for (const result of settled) {
if (isErr(result)) {
return err(result.error);
}
values.push(result.value);
}
return ok(values);
};
var ResultAsync = class _ResultAsync {
constructor(run) {
this.run = run;
}
run;
promise = null;
/**
* Lifts an already-settled sync {@link Result} into a {@link ResultAsync}.
*
* @example
* ```ts
* const ra = ResultAsync.fromResult(ok(42)); // ResultAsync<number, never>
* ```
*/
static fromResult(result) {
return new _ResultAsync(async () => result);
}
/**
* Wraps a `PromiseLike<T>` that may reject. Rejections pass through `onReject`
* to become a typed `Err`; resolution becomes `Ok<T>`.
*
* @param promise - the promise to wrap
* @param onReject - maps a rejection reason to the `Err` channel
*
* @example
* ```ts
* const body = ResultAsync.fromPromise(
* fetch(url).then((r) => r.text()),
* (e): NetError => ({ kind: "net", cause: String(e) }),
* );
* ```
*/
static fromPromise(promise, onReject) {
return new _ResultAsync(async () => {
try {
return ok(await promise);
} catch (error) {
return err(onReject(error));
}
});
}
/**
* Wraps a `PromiseLike<T>` that is **guaranteed not to reject**, skipping the
* `onReject` mapper. Use only when rejection is provably impossible.
*
* @example
* ```ts
* const now = ResultAsync.fromSafePromise(Promise.resolve(Date.now()));
* ```
*/
static fromSafePromise(promise) {
return new _ResultAsync(async () => ok(await promise));
}
/**
* Defers work until {@link resolve}. Unlike {@link fromPromise}, the factory
* does not run until the `ResultAsync` is resolved (e.g. by `combineTuple` /
* `combineTupleParallel`). The factory runs at most once — `resolve` memoizes.
*
* @example
* ```ts
* const insert = ResultAsync.defer(() => db.orders.insert(row));
* // nothing has run yet
* const r = await insert; // factory runs exactly once here
* ```
*/
static defer(fn) {
return new _ResultAsync(fn);
}
static ok(value) {
return new _ResultAsync(async () => ok(value));
}
static of(value) {
return _ResultAsync.ok(value);
}
/**
* Lifts an error into an `Err` async result.
*
* @example
* ```ts
* const r = ResultAsync.err({ kind: "not_found" as const });
* ```
*/
static err(error) {
return new _ResultAsync(async () => err(error));
}
/**
* Lifts an existing `Promise<Result<T, E>>` (e.g. from interop code) into a
* {@link ResultAsync}. A thrown defect is routed through `onDefect`,
* defaulting to {@link UnexpectedError}. The `Ok` value passes through
* verbatim — even when it happens to be Result-shaped.
*
* @param promise - a promise that already yields a `Result`
* @param onDefect - maps an unexpected throw to the `Err` channel
* @see {@link fromAsync} from `@onrails/result`
*/
static fromResultPromise(promise, onDefect) {
const mapDefect = onDefect ?? ((error) => new UnexpectedError("Unexpected async defect", error));
return new _ResultAsync(async () => {
try {
return await promise;
} catch (error) {
return err(mapDefect(error));
}
});
}
/**
* Combines a homogeneous array of async results into one. Resolves
* **sequentially** in input order, short-circuiting on the first `Err`.
* For heterogeneous tuples that preserve per-index types, use
* {@link combineTuple}; for wall-clock overlap, {@link combineTupleParallel}.
*
* @example
* ```ts
* const all = ResultAsync.combine([loadA(), loadB(), loadC()]);
* // ResultAsync<Item[], LoadError>
* ```
*/
static combine(results) {
return new _ResultAsync(async () => {
const values = [];
for (const ra of results) {
const result = await ra.resolve();
if (isErr(result)) {
return err(result.error);
}
values.push(result.value);
}
return ok(values);
});
}
/**
* Heterogeneous async tuple combine — resolves branches **sequentially**
* (left-to-right), returning the first `Err` in input order. Preserves each
* branch's `Ok` type by position, so the result destructures type-safely.
* This is the canonical sequential async combine (replaces the former
* `sequenceTupleAsync`).
*
* @example
* ```ts
* const combined = ResultAsync.combineTuple([loadCfg(), loadCatalog()] as const);
* // ResultAsync<readonly [Cfg, Catalog], CfgError | CatalogError>
* const r = await combined;
* if (isOk(r)) {
* const [cfg, catalog] = r.value; // typed per position
* }
* ```
*/
static combineTuple(results) {
return _ResultAsync.combine(
results
);
}
/**
* Like {@link combineTuple}, but starts every branch before awaiting (wall-clock
* parallel for independent IO). On failure, returns the first `Err` in input order.
*
* @example
* ```ts
* // independent IO — overlap them
* const combined = ResultAsync.combineTupleParallel([
* loadProfile(id),
* loadMetrics(id),
* ] as const);
* ```
*/
static combineTupleParallel(results) {
return new _ResultAsync(
async () => sequenceSettled(await Promise.all(results.map((ra) => ra.resolve())))
);
}
/**
* Transforms the `Ok` value, passing `Err` through unchanged.
*
* @example
* ```ts
* ResultAsync.ok(2).map((n) => n * 3); // ResultAsync<number> → Ok 6
* ```
*/
map(fn) {
return new _ResultAsync(async () => map(await this.resolve(), fn));
}
/**
* Transforms the `Err` value, passing `Ok` through unchanged — useful for
* unifying heterogeneous failures into one app-level union.
*
* @example
* ```ts
* load(id).mapErr((e): AppError => ({ kind: "load", cause: e })); // ResultAsync<T, AppError>
* ```
*/
mapErr(fn) {
return new _ResultAsync(async () => mapErr(await this.resolve(), fn));
}
/**
* Canonical bind — chains a step that itself returns a `ResultAsync` or sync
* `Result`, short-circuiting on `Err`. Error types accumulate (`E | F`).
*
* @example
* ```ts
* authenticate(req)
* .flatMap((user) => loadProfile(user.id)) // ResultAsync
* .flatMap((p) => validate(p)); // sync Result also accepted
* ```
*/
flatMap(fn) {
return new _ResultAsync(async () => {
const first = await this.resolve();
if (isErr(first)) {
return first;
}
const next = fn(first.value);
return next instanceof _ResultAsync ? next.resolve() : next;
});
}
/**
* neverthrow-compat alias of {@link flatMap}. Kept as the documented compat
* tier; prefer {@link flatMap} in new code.
*
* @example
* ```ts
* authenticate(req).andThen((user) => loadProfile(user.id));
* ```
*/
andThen(fn) {
return this.flatMap(fn);
}
/**
* Error-channel bind — runs `fn` only on `Err`, swapping in a recovery
* `ResultAsync` or sync `Result`. `Ok` passes through. The mirror of
* {@link flatMap} on the error track.
*
* @example
* ```ts
* loadFromCache(id).recover(() => loadFromOrigin(id)); // ResultAsync<T, F>
* ```
*/
recover(fn) {
return new _ResultAsync(async () => {
const first = await this.resolve();
if (!isErr(first)) {
return first;
}
const next = fn(first.error);
return next instanceof _ResultAsync ? next.resolve() : next;
});
}
/**
* neverthrow-compat alias of {@link recover}. Kept as the documented compat
* tier; prefer {@link recover} in new code.
*
* @example
* ```ts
* loadFromCache(id).orElse(() => loadFromOrigin(id));
* ```
*/
orElse(fn) {
return this.recover(fn);
}
/**
* Runs a side effect on the `Ok` value and passes the result through
* unchanged; a no-op on `Err`. Mirrors {@link tapErr}.
*
* @example
* ```ts
* saveUser(u).tap((saved) => analytics.track("user_saved", saved.id));
* ```
*/
tap(fn) {
return new _ResultAsync(async () => {
const result = await this.resolve();
if (!isErr(result)) {
fn(result.value);
}
return result;
});
}
/**
* Runs a side effect on the `Err` value and passes the result through
* unchanged; a no-op on `Ok`. The error-track mirror of {@link tap}.
*
* @example
* ```ts
* saveUser(u).tapErr((e) => logger.warn("save failed", e));
* ```
*/
tapErr(fn) {
return new _ResultAsync(async () => {
const result = await this.resolve();
if (isErr(result)) {
fn(result.error);
}
return result;
});
}
/**
* Settles to the `Ok` value, or `defaultValue` if `Err`. Terminal — returns a
* plain `Promise`, not a `ResultAsync`.
*
* @example
* ```ts
* const profile = await loadProfile(id).unwrapOr(guestProfile);
* ```
*/
unwrapOr(defaultValue) {
return this.resolve().then((result) => isErr(result) ? defaultValue : result.value);
}
/**
* Terminal collapse — folds both tracks into a single awaited value. Returns
* a plain `Promise`, settling the carrier exactly once.
*
* @param onOk - handles the `Ok` value
* @param onErr - handles the `Err` value
*
* @example
* ```ts
* const status = await save(row).match(
* () => 200,
* (e) => (e.kind === "conflict" ? 409 : 500),
* );
* ```
*/
match(onOk, onErr) {
return this.resolve().then(
(result) => isErr(result) ? onErr(result.error) : onOk(result.value)
);
}
/**
* Settles the carrier to a bare sync {@link Result}, memoizing so the
* underlying factory runs at most once. Prefer `await ra` (the thenable) or
* {@link match} in app code; use `resolve` when you need the tagged union back.
*
* @example
* ```ts
* const r = await load(id).resolve(); // Result<Data, LoadError>
* if (isOk(r)) use(r.value);
* ```
*/
resolve() {
if (!this.promise) {
this.promise = this.run();
}
return this.promise;
}
/**
* Thenable shim — `await ra` resolves to a bare tagged-union `Result<T, E>`.
* Narrow with `isOk(r)` / `isErr(r)` to read `.value` / `.error`.
*/
// biome-ignore lint/suspicious/noThenProperty: makes ResultAsync awaitable
then(onfulfilled, onrejected) {
return this.resolve().then(
// Safe: without onfulfilled, R1 stays at its Result<T, E> default.
(r) => onfulfilled ? onfulfilled(r) : r,
onrejected ?? void 0
);
}
};
// src/railway.ts
var addField = (ctx, key, value) => ({ ...ctx, [key]: value });
var Railway = class _Railway {
constructor(state) {
this.state = state;
}
state;
/** Start an empty sync workflow with no fields in context. */
static empty() {
return new _Railway({ mode: "sync", result: ok({}) });
}
/** Start a sync workflow with the given context as the initial state. */
static context(context) {
return new _Railway({ mode: "sync", result: ok(context) });
}
/** Start a sync workflow with a throwing function. */
static fromSync(key, fn, onThrow) {
return _Railway.empty().fromSync(key, fn, onThrow);
}
/** Start a sync workflow with a `Result`-returning function. */
static fromResult(key, fn) {
return _Railway.empty().fromResult(key, fn);
}
/** Start an async workflow with a `PromiseLike`-returning function. */
static fromPromise(key, fn, onReject) {
return _Railway.empty().fromPromise(key, fn, onReject);
}
/** Start an async workflow with a `ResultAsync`-returning function. */
static fromAsync(key, fn) {
return _Railway.empty().fromAsync(key, fn);
}
/**
* Rebuild the workflow in its current mode after transforming the carried
* result. Single re-link point for the state/phantom invariant: the mode
* tag is preserved verbatim, so `M` still describes the new state — TS
* cannot narrow the phantom `M` from the runtime tag, hence the cast.
*/
step(onSync, onAsync) {
const next = this.state.mode === "sync" ? { mode: "sync", result: onSync(this.state.result) } : { mode: "async", result: onAsync(this.state.result) };
return new _Railway(next);
}
/**
* Project the carried result into the mode-aware output type. Counterpart
* of {@link Railway.step} for terminal steps: the runtime branch matches
* the branch `RailwayOutput` picks for `M` (state/phantom invariant), but
* TS cannot resolve the conditional on an unresolved `M`, hence the cast.
*/
out(onSync, onAsync) {
return this.state.mode === "sync" ? onSync(this.state.result) : onAsync(this.state.result);
}
/** Lift the carried result into `ResultAsync` for async-upgrading steps. */
toAsync() {
return this.state.mode === "sync" ? ResultAsync.fromResult(this.state.result) : this.state.result;
}
/** Pure sync derivation. */
derive(key, fn) {
return this.fromResult(key, (ctx) => ok(fn(ctx)));
}
/** Throwing sync transform. */
fromSync(key, fn, onThrow) {
return this.fromResult(key, (ctx) => trySync(fn, onThrow)(ctx));
}
/** Sync `Result`-returning step. */
fromResult(key, fn) {
return this.step(
(result) => flatMap(result, (ctx) => map(fn(ctx), (value) => addField(ctx, key, value))),
(result) => result.flatMap(
(ctx) => ResultAsync.fromResult(fn(ctx)).map((value) => addField(ctx, key, value))
)
);
}
/** Promise-returning step — upgrades the workflow to async mode. */
fromPromise(key, fn, onReject) {
return this.fromAsync(key, (ctx) => ResultAsync.fromPromise(fn(ctx), onReject));
}
/** `ResultAsync`-returning step — upgrades the workflow to async mode. */
fromAsync(key, fn) {
return new _Railway({
mode: "async",
result: this.toAsync().flatMap((ctx) => fn(ctx).map((value) => addField(ctx, key, value)))
});
}
/** Narrow a nullable context field into a required non-null field. */
require(key, source, onMissing) {
return this.fromResult(key, (ctx) => {
const value = ctx[source];
return value == null ? err(onMissing(ctx)) : ok(value);
});
}
/** Run independent `ResultAsync` branches concurrently and merge outputs. */
parallel(branches) {
const merged = this.toAsync().flatMap(
(ctx) => ResultAsync.combineTupleParallel(
Object.entries(branches).map(([, branch]) => branch(ctx))
).map(
(values) => (
// Branch outputs land under exactly R's keys; Object.fromEntries
// erases that correspondence, so reassert the merged shape.
{
...ctx,
...Object.fromEntries(Object.keys(branches).map((key, index) => [key, values[index]]))
}
)
)
);
return new _Railway({
mode: "async",
// BranchFn erases per-key types to `ResultAsync<unknown, unknown>`
// (ParallelOutput/ParallelError recover them from R), so re-assert the
// precise value/error union the branches actually produce.
result: merged
});
}
/** Project the final context into the workflow's output type. */
select(fn) {
return this.out(
(result) => map(result, fn),
(result) => result.map(fn)
);
}
/** Return the accumulated context as-is. */
done() {
return this.out(
(result) => result,
(result) => result
);
}
};
export { Railway };
//# sourceMappingURL=railway.js.map
//# sourceMappingURL=railway.js.map