@joyful-tools/result
v0.4.0
Published
Typed result values for explicit error handling in synchronous and asynchronous code.
Readme
@joyful/result
Typed result values for explicit error handling in synchronous and asynchronous code.
@joyful/result gives you a small Rust-inspired Result API for representing
success as data with Ok<T> and failure as data with Err<E>. Instead of
throwing and catching exceptions throughout your code, you can make failure part
of the function signature and compose both paths directly.
Installation
# npm
npm install @joyful-tools/result
# pnpm
pnpm add @joyful-tools/result
# bun
bun add @joyful-tools/result
# npm (from JSR)
npx jsr add @joyful/result
# pnpm 10.9+ (from JSR)
pnpm add jsr:@joyful/result
# yarn 4.9+ (from JSR)
yarn add jsr:@joyful/result
# deno
deno add jsr:@joyful/resultThe examples below use
@joyful/result(the JSR name) in imports. If you installed from npm, use@joyful-tools/resultinstead.
Why Result?
- Make failure explicit in your types with
Result<T, E>. - Avoid exception-heavy control flow for expected failures.
- Transform success and error values with
map()andmapErr(). - Chain dependent operations with
andThen()and recover withorElse(). - Recover tagged errors with
orElseMatch()andorElseMatchSome(). - Model domain failures with
taggedError(). - Wrap throwing code with
Result.wrap()and rejecting code withAsyncResult.wrap(). - Retry transient failures with configurable backoff via
Result.retry(). - Propagate cancellation with
AsyncResult.Cancelledin signal-aware async helpers. - Compose complex flows with
Result.run()andyield*. - Use the same model for async code with
AsyncResult.
Quick Start
import { Result } from "@joyful/result";
function parsePort(input: string): Result<number, string> {
const port = Number(input);
return Number.isInteger(port) && port > 0
? Result.ok(port)
: Result.err("PORT must be a positive integer");
}
const parsed = parsePort("3000");
if (parsed.isOk()) {
console.log(parsed.value);
} else {
console.log(parsed.error);
}
const port = parsePort("invalid").unwrapOr(8080);
// 8080Creating Results
The most convenient constructors are the Result namespace helpers:
import { Result } from "@joyful/result";
const ok = Result.ok("ready");
const err = Result.err("missing configuration");The Ok and Err classes are also exported when you want to instantiate them
directly:
import { Err, Ok } from "@joyful/result";
const ok = new Ok(123);
const err = new Err("boom");Tagged Errors
Use taggedError() when you want rich Error objects with a stable _tag that
can be matched later.
import { taggedError } from "@joyful/result";
class ValidationError extends taggedError("ValidationError")<{
field: string;
}> {}
class NetworkError extends taggedError("NetworkError")<{
status: number;
}> {}
const error = new ValidationError({
field: "email",
message: "Email is required",
});
console.log(error._tag);
// "ValidationError"Tagged errors are normal Error instances, so they still work well with logs,
stack traces, and cause. They can also be yielded directly inside
Result.run() to return them as the failed result.
Working With Success And Error Paths
Narrowing with isOk() and isErr()
Use the type guards to branch safely and access .value or .error.
import { Result } from "@joyful/result";
function parseNumber(input: string): Result<number, string> {
const value = Number(input);
return Number.isNaN(value) ? Result.err("invalid number") : Result.ok(value);
}
const result = parseNumber("42");
if (result.isOk()) {
console.log(result.value);
}
if (result.isErr()) {
console.log(result.error);
}Mapping values with map() and mapErr()
import { Result } from "@joyful/result";
const value = Result.ok(2)
.map((number) => number + 1)
.map((number) => number * 10);
// Ok(30)
const error = Result.err("timeout")
.mapErr((message) => message.toUpperCase());
// Err("TIMEOUT")Chaining with andThen()
Use andThen() when the next step can also fail and already returns a Result.
import { Result } from "@joyful/result";
function parseNumber(input: string): Result<number, string> {
const value = Number(input);
return Number.isNaN(value) ? Result.err("invalid number") : Result.ok(value);
}
function requirePositive(value: number): Result<number, string> {
return value > 0 ? Result.ok(value) : Result.err("value must be positive");
}
const result = parseNumber("42").andThen(requirePositive);
// Ok(42)Recovering with orElse()
Use orElse() when you want to turn an error into another Result.
import { Result } from "@joyful/result";
const value = Result.err("missing value").orElse((error) =>
error === "missing value" ? Result.ok("default") : Result.err(error)
);
// Ok("default")Recovering tagged errors with orElseMatch()
Use orElseMatch() when your error type is a tagged error union and you want an
exhaustive recovery map.
import { Result, taggedError } from "@joyful/result";
class ValidationError extends taggedError("ValidationError")<{
field: string;
}> {}
class NetworkError extends taggedError("NetworkError")<{
status: number;
}> {}
const result: Result<string, ValidationError | NetworkError> = Result.err(
new ValidationError({ field: "email" }),
);
const recovered = result.orElseMatch({
ValidationError: (error) => Result.ok(`guest:${error.field}`),
NetworkError: (error) => Result.err(`retry:${error.status}`),
});
// Ok("guest:email") | Err("retry:...")Each handler must return a Result, so orElseMatch() fits naturally when you
want to recover, remap, or continue composing in the result model.
Recovering some tagged errors with orElseMatchSome()
Use orElseMatchSome() when you only want to handle part of a tagged error
union and leave the rest untouched.
import { Result, taggedError } from "@joyful/result";
class ValidationError extends taggedError("ValidationError")<{
field: string;
}> {}
class NetworkError extends taggedError("NetworkError")<{
status: number;
}> {}
const result: Result<string, ValidationError | NetworkError> = Result.err(
new NetworkError({ status: 503 }),
);
const recovered = result.orElseMatchSome({
ValidationError: (error) => Result.ok(`fixed:${error.field}`),
});
if (recovered.isErr()) {
console.log(recovered.error._tag);
// "NetworkError"
}This is useful when some errors can be recovered locally and others should keep flowing upward.
Wrapping throwing code with Result.wrap()
Result.wrap() is useful when you need to integrate exception-based code into a
result flow. It runs a callback, returns Ok for success, and maps thrown
errors with your catch function.
import { Result } from "@joyful/result";
const parsed = Result.wrap({
try: () => JSON.parse('{"port":3000}') as { port: number },
catch: () => "invalid json",
});
// Ok({ port: 3000 })Thrown values become Err results:
const parsed = Result.wrap({
try: () => JSON.parse("not json"),
catch: (error) => error instanceof Error ? error.message : String(error),
});
// Err("Unexpected token 'o', \"not json\" is not valid JSON")Retrying Fallible Operations With Result.retry
Result.retry() calls a result-returning factory function repeatedly until it
succeeds or a retry schedule is exhausted. It is modeled after the Deno KV queue
backoff pattern: you supply a schedule array of delays (in ms), where the
array length determines the maximum number of retries and each element is the
delay before that retry.
Basic retry with default schedule
Without options, Result.retry() uses the default schedule of
[1000, 5000, 10000] (up to 3 retries with 1 s, 5 s, and 10 s delays):
import { AsyncResult, Result } from "@joyful/result";
const result = await Result.retry(
(attempt) =>
AsyncResult.wrap({
try: () => fetch("/api/data"),
catch: (e) => (e instanceof Error ? e.message : String(e)),
}),
);The factory receives the current attempt number (0-indexed). On the first
call, attempt is 0.
Custom schedule
Pass schedule to control backoff durations and the number of retries:
import { AsyncResult, Result } from "@joyful/result";
const result = await Result.retry(
(attempt) =>
AsyncResult.wrap({
try: () => fetch("/api/data"),
catch: (e) => (e instanceof Error ? e.message : String(e)),
}),
{ schedule: [100, 200, 400] },
);An empty schedule ([]) means no retries — the factory runs exactly once.
Early exit with while
The while predicate is called after each failure. Return false to stop
retrying immediately. This is useful when certain errors are not transient:
import { AsyncResult, Result, taggedError } from "@joyful/result";
class NotFoundError extends taggedError("NotFoundError")<{
url: string;
}> {}
class TimeoutError extends taggedError("TimeoutError")<{
ms: number;
}> {}
const result = await Result.retry(
(attempt) =>
AsyncResult.wrap({
try: () => fetch("/api/data"),
catch: classifyError,
}),
{
schedule: [500, 1000, 2000],
while: (err) => err._tag !== "NotFoundError",
},
);When while returns false, the current error is returned as-is (not wrapped
in RetriesExhausted).
The while callback also supports returning a Promise<boolean>, which is
useful when the retry decision depends on async checks:
const result = await Result.retry(
(attempt) =>
AsyncResult.wrap({
try: () => fetch("/api/data"),
catch: classifyError,
}),
{
while: async (err, attempt) => {
const healthy = await checkServiceHealth();
return healthy;
},
},
);Handling RetriesExhausted
When the schedule is fully exhausted, the final error is wrapped in
Result.RetriesExhausted. This tagged error carries the total number of
attempts and the last error encountered:
import { AsyncResult, Result } from "@joyful/result";
const result = await Result.retry(
() =>
AsyncResult.wrap({
try: () => fetch("/api/data"),
catch: (e) => (e instanceof Error ? e.message : String(e)),
}),
{ schedule: [100, 200] },
);
if (result.isErr() && result.error instanceof Result.RetriesExhausted) {
console.log(result.error.attempts); // 3 (1 initial + 2 retries)
console.log(result.error.lastError); // the error from the final attempt
}You can also match on RetriesExhausted with orElseMatchSome():
const recovered = result.orElseMatchSome({
RetriesExhausted: (err) => Result.ok(fallbackValue),
});Composing with Result.run
Result.retry() returns an AsyncResult, so it composes naturally with
yield* inside Result.run:
import { AsyncResult, Result } from "@joyful/result";
const result = await Result.run(async function* () {
const data = yield* Result.retry(
() =>
AsyncResult.wrap({
try: () => fetch("/api/data").then((r) => r.json()),
catch: (e) => (e instanceof Error ? e.message : String(e)),
}),
{ schedule: [500, 1000] },
);
return data;
});Generator Composition With Result.run
Result.run() lets you write sequential result logic with yield* instead of
nesting andThen() calls. Ok values continue the generator with their
unwrapped value, Err values stop the generator early and become the final
result, and the generator's returned value becomes the successful result. If the
generator returns a Result, it is flattened once. Tagged errors created with
taggedError() can also be yielded directly to stop the generator with that
error.
Synchronous generators
import { Result } from "@joyful/result";
function parseNumber(input: string): Result<number, string> {
const value = Number(input);
return Number.isNaN(value) ? Result.err("invalid number") : Result.ok(value);
}
function requirePositive(value: number): Result<number, string> {
return value > 0 ? Result.ok(value) : Result.err("value must be positive");
}
const result = Result.run(function* () {
const parsed = yield* parseNumber("42");
const positive = yield* requirePositive(parsed);
return positive * 2;
});
// Ok(84)Returning a Result is also supported and flattened once:
const result = Result.run(function* () {
const parsed = yield* parseNumber("42");
return requirePositive(parsed);
});
// Ok(42) or Err("value must be positive")Synchronous generators cannot flatten returned AsyncResult values because that
would require awaiting async work. If a sync generator returns an AsyncResult,
the runtime value is Ok(asyncResult), but the TypeScript return type is a
unique-symbol diagnostic object that points you toward using async function*
instead.
const wrong = Result.run(function* () {
return fetchUser(); // AsyncResult<User, FetchError>
});
// { readonly [resultRunSyncAsyncResultError]: "Result.run returned an AsyncResult from a sync generator. Use async function* instead." }Use an async generator when returning or yielding async work:
const right = Result.run(async function* () {
return fetchUser();
});
// AsyncResult<User, FetchError>If any yielded result is an error, execution stops immediately:
const result = Result.run(function* () {
const parsed = yield* parseNumber("nope");
return parsed * 2;
});
// Err("invalid number")Tagged errors can be yielded directly:
import { Result, taggedError } from "@joyful/result";
class InactiveUser extends taggedError("InactiveUser")<{
id: string;
}> {}
const result = Result.run(function* () {
const user = yield* getUser("123");
if (!user.active) yield* new InactiveUser({ id: user.id });
return user.name;
});Defaults, assertions, and side effects
unwrapOr(defaultValue)returns the success value or a fallback.expect(message)returns the success value or throws anError.expectErr(message)returns the error value or throws anError.inspect(fn)runsfnon the success value and returns the same result.inspectErr(fn)runsfnon the error value and returns the same result.
expect() and expectErr() are best reserved for tests or states that should
be impossible in normal execution.
AsyncResult
AsyncResult<T, E> is a promise-like wrapper around Promise<Result<T, E>>. It
lets you keep using result-style composition in async code without dropping back
to exceptions or manual branching at every step.
Converting a sync result with .async()
Every Ok and Err can be converted into an AsyncResult.
import { Result } from "@joyful/result";
const value = await Result.ok(2)
.async()
.map((number) => number + 1)
.unwrapOr(0);
// 3Wrapping async work
When you have async work and want to capture thrown or rejected values as data,
use AsyncResult.wrap():
import { AsyncResult } from "@joyful/result";
const config = await AsyncResult.wrap({
try: async () => {
const response = await fetch("https://example.com/config.json");
if (!response.ok) {
throw new Error(`request failed: ${response.status}`);
}
return response.json();
},
catch: (error) => error instanceof Error ? error.message : String(error),
});Pass { signal } when you want cancellation to be reflected in the result type
as AsyncResult.Cancelled. The signal is only passed to try in this abortable
form:
import { AsyncResult, Result } from "@joyful/result";
const signal = AbortSignal.timeout(5000);
const config = await AsyncResult.wrapAbortable({
try: async (signal) => {
const response = await fetch("https://example.com/config.json", {
signal,
});
return response.json();
},
catch: (error) => error instanceof Error ? error.message : String(error),
}, { signal });
if (config.isErr() && config.error instanceof AsyncResult.Cancelled) {
console.error("request cancelled");
}You can still build an AsyncResult manually when you need to work at the
Promise<Result<T, E>> level:
import { AsyncResult, Result } from "@joyful/result";
function fetchJson(url: string): AsyncResult<unknown, string> {
return new AsyncResult(
fetch(url)
.then(async (response) => {
if (!response.ok) {
return Result.err(`request failed: ${response.status}`);
}
return Result.ok(await response.json());
})
.catch((error) =>
Result.err(error instanceof Error ? error.message : String(error))
),
);
}
const json = await fetchJson("https://example.com/api/user").unwrapOr(null);Async composition
AsyncResult supports the same core operations as Result, and its callbacks
may be synchronous or asynchronous.
import { Result } from "@joyful/result";
const value = await Result.ok(2)
.async()
.map(async (number) => number + 1)
.andThen((number) => Result.ok(number * 10).async())
.unwrapOr(0);
// 30orElseMatch() and orElseMatchSome() are available on AsyncResult too.
Handlers may return a Result, an AsyncResult, or a Promise<Result>.
import { Result, taggedError } from "@joyful/result";
class ValidationError extends taggedError("ValidationError")<{
field: string;
}> {}
class NetworkError extends taggedError("NetworkError")<{
status: number;
}> {}
const result = Result.err<string, ValidationError | NetworkError>(
new ValidationError({ field: "email" }),
).async();
const recovered = await result.orElseMatchSome({
ValidationError: async (error) => Result.ok(error.field.length),
NetworkError: (error) => Result.err(`retry:${error.status}`),
});Async generators with Result.run
Result.run() also accepts an async generator, which makes it useful when you
want the same yield* flow over AsyncResult values.
import { AsyncResult, Result } from "@joyful/result";
function fetchScore(): AsyncResult<number, string> {
return new AsyncResult(Promise.resolve(Result.ok(21)));
}
const result = await Result.run(async function* () {
const score = yield* fetchScore();
const bonus = yield* Result.ok(2).async();
return score * bonus;
});
// Ok(42)For cancellable async flows, keep Result.run() as plain composition and use
AsyncResult.wrapAbortable() at the boundary where promise-based work is
created:
import { AsyncResult, Result } from "@joyful/result";
const signal = AbortSignal.timeout(5000);
const result = await Result.run(async function* () {
const config = yield* AsyncResult.wrapAbortable({
try: async (signal) => {
const response = await fetch("https://example.com/config.json", {
signal,
});
return response.json();
},
catch: (error) => error instanceof Error ? error.message : String(error),
}, { signal });
return config.theme;
});
if (result.isErr() && result.error instanceof AsyncResult.Cancelled) {
console.error("cancelled");
}API Overview
Result<T, E>: union type ofOk<T, E>andErr<T, E>.Result.ok(value): create a successful result.Result.err(error): create a failed result.taggedError(tag): create taggedErrorsubclasses for domain errors.AsyncResult.Cancelled: shared cancellation outcome for abortable async helpers.Result.wrap(options): convert throwing synchronous code into a result.AsyncResult.wrap(options): convert throwing or rejecting async code into an async result.AsyncResult.wrapAbortable(options, { signal }): convert abortable async code into an async result.Result.retry(fn, options?): retry a result-returning function with configurable backoff.Result.RetriesExhausted: tagged error when all retry attempts are exhausted.OkandErr: concrete classes with.valueand.errorfields.map()andmapErr(): transform success and error values.andThen()andorElse(): compose additional result-returning operations.orElseMatch()andorElseMatchSome(): recover tagged errors with handler maps.unwrapOr(),expect(),expectErr(): extract values.inspect()andinspectErr(): observe values without changing them.Result.run(): compose results with generator or async generator control flow.async(): convert aResulttoAsyncResult.AsyncResult: async wrapper with the same composition primitives.
Which Method Should I Use?
map(): transform a successful value without changing the error type.mapErr(): transform an error value without changing the success type.andThen(): continue with another operation that already returns aResult.orElse(): recover from any error value with one fallback function.orElseMatch(): exhaustively recover tagged errors with one handler per_tag.orElseMatchSome(): recover only selected tagged errors and leave the rest unchanged.
License
MIT
