Lightweight Result type for TypeScript with generator-based composition.
better-result
Lightweight Result type for TypeScript with generator-based composition.
๐ Documentation
Install
npm install better-result
Or with Bun / pnpm:
bun add better-result
pnpm add better-result
Quick Start
import { Result } from "better-result";
// Wrap throwing functions const parsed = Result.try(() => JSON.parse(input));
// Check and use if (Result.isOk(parsed)) { console.log(parsed.value); } else { console.error(parsed.error); }
// Or use pattern matching const message = parsed.match({ ok: (data) => Got: ${data.name}, err: (e) => Failed: ${e.message}, });
Contents
- Creating Results
- Transforming Results
- Handling Errors
- Observing Results
- Extracting Values
- Generator Composition
- Retry Support
- UnhandledException
- Panic
- Tagged Errors
- Serialization
- API Reference
- Agents & AI
Creating Results
// Success
const ok = Result.ok(42);
// Error const err = Result.err(new Error("failed"));
// From throwing function const result = Result.try(() => riskyOperation());
// From promise const result = await Result.tryPromise(() => fetch(url));
// With custom error handling const result = Result.try({ try: () => JSON.parse(input), catch: (e) => new ParseError(e), });
Transforming Results
const result = Result.ok(2)
.map((x) => x * 2) // Ok(4)
.andThen(
(
x, // Chain Result-returning functions
) => (x > 0 ? Result.ok(x) : Result.err("negative")),
);
// Standalone functions (data-first or data-last) Result.map(result, (x) => x + 1); Result.map((x) => x + 1)(result); // Pipeable
Handling Errors
// Transform error type
const result = fetchUser(id).mapError((e) => new AppError(Failed to fetch user: ${e.message}));
// Recover from specific errors while preserving the same success type const result = fetchUser(id).tryRecover((e) => e._tag === "NotFoundError" ? Result.ok(defaultUser) : Result.err(e), );
// Async recovery follows the same pattern // If fetchUser is async and returns Promise<Result<User, E>>, await it first. const result = await ( await fetchUser(id) ).tryRecoverAsync(async (e) => e._tag === "NetworkError" ? Result.ok(await readUserFromCache(id)) : Result.err(e), );
Observing Results
Use tap / tapAsync for success-side logging or tracing, tapError / tapErrorAsync for error-side logging or tracing, and tapBoth / tapBothAsync when you want to observe either branch with one handler object. These methods do not transform the Result โ they always return the original value unchanged.
const result = Result.try(() => JSON.parse(input))
.tap((value) => {
console.debug("parsed payload", value);
})
.tapError((error) => {
console.error("failed to parse payload", error);
});
If you want to observe both branches symmetrically with one call, use tapBoth:
const result = Result.try(() => JSON.parse(input)).tapBoth({
ok: (value) => {
console.info("decoded payload", value);
},
err: (error) => {
console.warn("decode failed", error);
},
});
Async side effects follow the same pattern:
const result = await Result.err("request failed").tapErrorAsync(async (error) => {
await trace("request.failed", { error });
});
tapBothAsync works the same way for async observers on either branch:
const observed = await Result.tapBothAsync(
Result.try(() => JSON.parse(input)),
{
ok: async (value) => {
await trace("payload.decoded", { value });
},
err: async (error) => {
await trace("payload.decode_failed", { error });
},
},
);
Static helpers support both data-first and data-last styles:
const traced = Result.tapError(Result.err("cache miss"), (error) => {
console.warn("cache lookup failed", error);
});
const traceError = Result.tapErrorAsync(async (error: string) => { await trace("cache.lookup_failed", { error }); });
await traceError(Result.err("cache miss"));
If you prefer, you can still observe both branches by chaining tap and tapError separately.
Thrown or rejected side-effect callbacks become Panic, just like other Result callbacks.
Extracting Values
// Unwrap (throws on Err)
const value = result.unwrap();
const value = result.unwrap("custom error message");
// With fallback const value = result.unwrapOr(defaultValue);
// Pattern match const value = result.match({ ok: (v) => v, err: (e) => fallback, });
Generator Composition
Chain multiple Results without nested callbacks or early returns:
const result = Result.gen(function* () {
const a = yield* parseNumber(inputA); // Unwraps or short-circuits
const b = yield* parseNumber(inputB);
const c = yield* divide(a, b);
return Result.ok(c);
});
// Result<number, ParseError | DivisionError>
Async version with Result.await:
const result = await Result.gen(async function* () {
const user = yield* Result.await(fetchUser(id));
const posts = yield* Result.await(fetchPosts(user.id));
return Result.ok({ user, posts });
});
Errors from all yielded Results are automatically collected into the final error union type.
Normalizing Error Types
Use mapError on the output of Result.gen() to unify multiple error types into a single type:
class ParseError extends TaggedError("ParseError")<{ message: string }>() {}
class ValidationError extends TaggedError("ValidationError")<{ message: string }>() {}
class AppError extends TaggedError("AppError")<{ source: string; message: string }>() {}
const result = Result.gen(function* () { const parsed = yield* parseInput(input); // Err: ParseError const valid = yield* validate(parsed); // Err: ValidationError return Result.ok(valid); }).mapError((e): AppError => new AppError({ source: e._tag, message: e.message })); // Result<ValidatedData, AppError> - error union normalized to single type
Retry Support
const result = await Result.tryPromise(() => fetch(url), {
retry: {
times: 3,
delayMs: 100,
backoff: "exponential", // or "linear" | "constant"
},
});
The try callback receives a TryContext with a 1-based attempt number:
const result = await Result.tryPromise(({ attempt }) => fetchWithRetryContext(url, attempt), {
retry: {
times: 3,
delayMs: 100,
backoff: "constant",
},
});
Conditional Retry
Retry only for specific error types using shouldRetry:
class NetworkError extends TaggedError("NetworkError")<{ message: string }>() {}
class ValidationError extends TaggedError("ValidationError")<{ message: string }>() {}
const result = await Result.tryPromise( { try: () => fetchData(url), catch: (e) => e instanceof TypeError // Network failures often throw TypeError ? new NetworkError({ message: (e as Error).message }) : new ValidationError({ message: String(e) }), }, { retry: { times: 3, delayMs: 100, backoff: "exponential", shouldRetry: (e) => e._tag === "NetworkError", // Only retry network errors }, }, );
Async Retry Decisions
For retry decisions that require async operations (rate limits, feature flags, etc.), enrich the error in the catch handler instead of making shouldRetry async:
class ApiError extends TaggedError("ApiError")<{
message: string;
rateLimited: boolean;
}>() {}
const result = await Result.tryPromise( { try: () => callApi(url), catch: async (e) => { // Fetch async state in catch handler const retryAfter = await redis.get(ratelimit:${userId}); return new ApiError({ message: (e as Error).message, rateLimited: retryAfter !== null, }); }, }, { retry: { times: 3, delayMs: 100, backoff: "exponential", shouldRetry: (e) => !e.rateLimited, // Sync predicate uses enriched error }, }, );
UnhandledException
When Result.try() or Result.tryPromise() catches an exception without a custom handler, the error type is UnhandledException:
import { Result, UnhandledException } from "better-result";
// Automatic โ error type is UnhandledException const result = Result.try(() => JSON.parse(input)); // ^? Result<unknown, UnhandledException>
// Custom handler โ you control the error type const result = Result.try({ try: () => JSON.parse(input), catch: (e) => new ParseError(e), }); // ^? Result<unknown, ParseError>
// Same for async await Result.tryPromise(() => fetch(url)); // ^? Promise<Result<Response, UnhandledException>>
Access the original exception via .cause:
if (Result.isError(result)) {
const original = result.error.cause;
if (original instanceof SyntaxError) {
// Handle JSON parse error
}
}
Panic
Thrown (not returned) when user callbacks throw inside Result operations. Represents a defect in your code, not a domain error.
import { Panic, isPanic } from "better-result";
// Callback throws โ Panic Result.ok(1).map(() => { throw new Error("bug"); }); // throws Panic
// Generator cleanup throws โ Panic Result.gen(function* () { try { yield* Result.err("expected failure"); } finally { throw new Error("cleanup bug"); } }); // throws Panic
// Catch handler throws โ Panic Result.try({ try: () => riskyOp(), catch: () => { throw new Error("bug in handler"); }, }); // throws Panic
// Catching Panic (for error reporting) try { result.map(() => { throw new Error("bug"); }); } catch (error) { if (isPanic(error)) { // isPanic() is a type guard function console.error("Defect:", error.message, error.cause); }
if (Panic.is(error)) { // Panic.is() is a static method (same behavior) }
if (error instanceof Panic) { // instanceof works too } }
Why Panic? Err is for recoverable domain errors. Panic is for bugs โ like Rust's panic!(). If your .map() callback throws, that's not an error to handle, it's a defect to fix. Returning Err would collapse type safety (Result<T, E> becomes Result<T, E | unknown>).
Panic properties:
| Property | Type | Description | | --------- | --------- | ----------------------------- | | message | string | Describes where/what panicked | | cause | unknown | The exception that was thrown |
Panic also provides toJSON() for error reporting services (Sentry, etc.).
Tagged Errors
Build exhaustive error handling with discriminated unions:
import { Result, TaggedError, matchError, matchErrorPartial } from "better-result";
// Factory API: TaggedError("Tag")<Props>() class NotFoundError extends TaggedError("NotFoundError")<{ id: string; message: string; }>() {}
class ValidationError extends TaggedError("ValidationError")<{ field: string; message: string; }>() {}
type AppError = NotFoundError | ValidationError;
// Create errors with object args const err = new NotFoundError({ id: "123", message: "User not found" });
// Exhaustive matching matchError(error, { NotFoundError: (e) => Missing: ${e.id}, ValidationError: (e) => Bad field: ${e.field}, });
// Partial matching with fallback matchErrorPartial( error, { NotFoundError: (e) => Missing: ${e.id} }, (e) => Unknown: ${e.message}, );
// Type guards TaggedError.is(value); // any TaggedError instance, including toJSON() NotFoundError.is(value); // specific class
Yielding Tagged Errors in Result.gen
Tagged errors can short-circuit Result.gen directly. This is useful for recoverable domain errors and is equivalent to yielding Result.err(error); it does not throw.
const result = Result.gen(function* () {
yield* new NotFoundError({ id: "123", message: "missing" });
return Result.ok("never reached");
});
// Result<string, NotFoundError>
// => Err(original NotFoundError instance)
They also compose with regular Result values and contribute to the inferred error union:
const result = Result.gen(function* () {
const user = yield* findUser("123"); // Result<User, NotFoundError>
if (!user.active) { yield* new ValidationError({ field: "active", message: "User is inactive" }); }
return Result.ok(user); }); // Result<User, NotFoundError | ValidationError>
For errors with computed messages, add a custom constructor:
class NetworkError extends TaggedError("NetworkError")<{
url: string;
status: number;
message: string;
}>() {
constructor(args: { url: string; status: number }) {
super({ ...args, message: Request to ${args.url} failed: ${args.status} });
}
}
new NetworkError({ url: "/api", status: 404 });
Serialization
Convert Results to plain objects for RPC, storage, or server actions:
import { Result, SerializedResult, ResultDeserializationError } from "better-result";
// Serialize to plain object const result = Result.ok(42); const serialized = Result.serialize(result); // { status: "ok", value: 42 }
// Deserialize back to Result instance const deserialized = Result.deserialize<number, never>(serialized); // Ok(42) - can use .map(), .andThen(), etc.
// Invalid input returns ResultDeserializationError const invalid = Result.deserialize({ foo: "bar" }); if (Result.isError(invalid) && ResultDeserializationError.is(invalid.error)) { console.log("Bad input:", invalid.error.value); }
// Typed boundary for Next.js server actions async function createUser(data: FormData): Promise<SerializedResult<User, ValidationError>> { const result = await validateAndCreate(data); return Result.serialize(result); }
// Client-side const serialized = await createUser(formData); const result = Result.deserialize<User, ValidationError>(serialized);
API Reference
Result
| Method | Description | | --------------------------------------- | ---------------------------------------------------------------------------------------- | | Result.ok(value) | Create success | | Result.err(error) | Create error | | Result.try(fn) | Wrap throwing function | | Result.tryPromise(fn, config?) | Wrap async function with optional retry | | Result.isOk(result) | Type guard for Ok | | Result.isError(result) | Type guard for Err | | Result.gen(fn) | Generator composition | | Result.tryRecover(result, fn) | Recover error into same success type | | Result.tryRecoverAsync(result, fn) | Async recover error into same success type | | Result.tap(result, fn) | Run side effect on success and return original result | | Result.tapAsync(result, fn) | Run async side effect on success and return original result | | Result.tapError(result, fn) | Run side effect on error and return original result | | Result.tapErrorAsync(result, fn) | Run async side effect on error and return original result | | Result.tapBoth(result, handlers) | Run side effect on either branch and return original result | | Result.tapBothAsync(result, handlers) | Run async side effect on either branch and return original result | | Result.await(promise) | Wrap PromiseResult.serialize(result) | Convert Result to plain object | | Result.deserialize(value) | Rehydrate serialized Result (returns Err<ResultDeserializationError> on invalid input) | | Result.partition(results) | Split array into [okValues, errValues] | | Result.flatten(result) | Flatten nested Result |
Instance Methods
| Method | Description | | ------------------------- | ------------------------------------------ | | .isOk() | Type guard, narrows to Ok | | .isErr() | Type guard, narrows to Err | | .map(fn) | Transform success value | | .mapError(fn) | Transform error value | | .tryRecover(fn) | Recover error into same success type | | .tryRecoverAsync(fn) | Async recover error into same success type | | .andThen(fn) | Chain Result-returning function | | .andThenAsync(fn) | Chain async Result-returning function | | .match({ ok, err }) | Pattern match | | .unwrap(message?) | Extract value or throw | | .unwrapOr(fallback) | Extract value or return fallback | | .tap(fn) | Side effect on success | | .tapAsync(fn) | Async side effect on success | | .tapError(fn) | Side effect on error | | .tapErrorAsync(fn) | Async side effect on error | | .tapBoth(handlers) | Side effect on either branch | | .tapBothAsync(handlers) | Async side effect on either branch |
TaggedError
| Method | Description | | -------------------------------------- | ---------------------------------- | | TaggedError(tag)<Props>() | Factory for tagged error class | | TaggedError.is(value) | Type guard for any TaggedError | | matchError(err, handlers) | Exhaustive pattern match by _tag | | matchErrorPartial(err, handlers, fb) | Partial match with fallback | | isTaggedError(value) | Type guard (standalone function) | | panic(message, cause?) | Throw unrecoverable Panic | | isPanic(value) | Type guard for Panic |
Type Helpers
| Type | Description | | ------------------------ | ---------------------------- | | InferOk<R> | Extract Ok type from Result | | InferErr<R> | Extract Err type from Result | | AnyTaggedError | Generic TaggedError instance | | SerializedResult<T, E> | Plain object form of Result | | SerializedOk<T> | Plain object form of Ok | | SerializedErr<E> | Plain object form of Err |
Agents & AI
better-result ships with portable SKILL.md skills instead of an interactive CLI.
Available skills
better-result-adoptโ adoptbetter-resultin an existing codebasebetter-result-migrate-v2โ migrate v1TaggedErrorusage to the v2 API
Install with skills.sh-compatible tooling
npx skills add dmmulroy/better-result@better-result-adopt
npx skills add dmmulroy/better-result@better-result-migrate-v2
To install globally without prompts:
npx skills add dmmulroy/better-result@better-result-adopt -g -y
Manual installation
If your agent does not support skills.sh installation, copy one of these directories into the agent's skills folder:
skills/better-result-adopt/skills/better-result-migrate-v2/
What the skills do
better-result-adopt guides an agent through:
- converting try/catch to
Result.try/Result.tryPromise - defining
TaggedErrorclasses for domain errors - refactoring nested error handling into
Result.gen - replacing nullable or sentinel error returns with
Result
better-result-migrate-v2 guides an agent through:
- migrating
TaggedErrorclasses from v1 to v2 factory syntax - updating constructor call sites to the new object form
- replacing
TaggedError.match*helpers with standalone helpers - updating imports and verifying no old API usages remain
Optional source context
For richer AI context in a consuming project:
npx opensrc better-result
See skills/README.md for a concise skill-install reference.
License
MIT