better-result
Lightweight Result type for TypeScript with generator-based composition.
Install
New to better-result?
npx better-result init
Upgrading from v1?
npx better-result migrate
Quick Start
import { Result } from "better-result";
const parsed = Result.try(() => JSON.parse(input));
if (Result.isOk(parsed)) {
console.log(parsed.value);
} else {
console.error(parsed.error);
}
const message = parsed.match({
ok: (data) => `Got: ${data.name}`,
err: (e) => `Failed: ${e.message}`,
});
Contents
Creating Results
const ok = Result.ok(42);
const err = Result.err(new Error("failed"));
const result = Result.try(() => riskyOperation());
const result = await Result.tryPromise(() => fetch(url));
const result = Result.try({
try: () => JSON.parse(input),
catch: (e) => new ParseError(e),
});
Transforming Results
const result = Result.ok(2)
.map((x) => x * 2)
.andThen(
(
x,
) => (x > 0 ? Result.ok(x) : Result.err("negative")),
);
Result.map(result, (x) => x + 1);
Result.map((x) => x + 1)(result);
Handling Errors
const result = fetchUser(id).mapError((e) => new AppError(`Failed to fetch user: ${e.message}`));
const result = fetchUser(id).match({
ok: (user) => Result.ok(user),
err: (e) => (e._tag === "NotFoundError" ? Result.ok(defaultUser) : Result.err(e)),
});
const value = result.unwrap();
const value = result.unwrap("custom error message");
const value = result.unwrapOr(defaultValue);
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);
const b = yield* parseNumber(inputB);
const c = yield* divide(a, b);
return Result.ok(c);
});
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);
const valid = yield* validate(parsed);
return Result.ok(valid);
}).mapError((e): AppError => new AppError({ source: e._tag, message: e.message }));
Retry Support
const result = await Result.tryPromise(() => fetch(url), {
retry: {
times: 3,
delayMs: 100,
backoff: "exponential",
},
});
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
? new NetworkError({ message: (e as Error).message })
: new ValidationError({ message: String(e) }),
},
{
retry: {
times: 3,
delayMs: 100,
backoff: "exponential",
shouldRetry: (e) => e._tag === "NetworkError",
},
},
);
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) => {
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,
},
},
);
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";
const result = Result.try(() => JSON.parse(input));
const result = Result.try({
try: () => JSON.parse(input),
catch: (e) => new ParseError(e),
});
await Result.tryPromise(() => fetch(url));
Access the original exception via .cause:
if (Result.isError(result)) {
const original = result.error.cause;
if (original instanceof SyntaxError) {
}
}
Panic
Thrown (not returned) when user callbacks throw inside Result operations. Represents a defect in your code, not a domain error.
import { Panic } from "better-result";
Result.ok(1).map(() => {
throw new Error("bug");
});
Result.gen(function* () {
try {
yield* Result.err("expected failure");
} finally {
throw new Error("cleanup bug");
}
});
Result.try({
try: () => riskyOp(),
catch: () => {
throw new Error("bug in handler");
},
});
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:
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 { TaggedError, matchError, matchErrorPartial } from "better-result";
class NotFoundError extends TaggedError("NotFoundError")<{
id: string;
message: string;
}>() {}
class ValidationError extends TaggedError("ValidationError")<{
field: string;
message: string;
}>() {}
type AppError = NotFoundError | ValidationError;
const err = new NotFoundError({ id: "123", message: "User not found" });
matchError(error, {
NotFoundError: (e) => `Missing: ${e.id}`,
ValidationError: (e) => `Bad field: ${e.field}`,
});
matchErrorPartial(
error,
{ NotFoundError: (e) => `Missing: ${e.id}` },
(e) => `Unknown: ${e.message}`,
);
TaggedError.is(value);
NotFoundError.is(value);
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";
const result = Result.ok(42);
const serialized = Result.serialize(result);
const deserialized = Result.deserialize<number, never>(serialized);
const invalid = Result.deserialize({ foo: "bar" });
if (Result.isError(invalid) && ResultDeserializationError.is(invalid.error)) {
console.log("Bad input:", invalid.error.value);
}
async function createUser(data: FormData): Promise<SerializedResult<User, ValidationError>> {
const result = await validateAndCreate(data);
return Result.serialize(result);
}
const serialized = await createUser(formData);
const result = Result.deserialize<User, ValidationError>(serialized);
API Reference
Result
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.await(promise) | Wrap Promise for generators |
Result.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
.isOk() | Type guard, narrows to Ok |
.isErr() | Type guard, narrows to Err |
.map(fn) | Transform success value |
.mapError(fn) | Transform error value |
.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 |
TaggedError
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
InferOk<R> | Extract Ok type from Result |
InferErr<R> | Extract Err type from Result |
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 skills for AI coding agents (OpenCode, Claude Code, Codex).
Quick Start
npx better-result init
Interactive setup that:
- Installs the better-result package
- Optionally fetches source code via opensrc for better AI context
- Installs the adoption skill +
/adopt-better-result command for your agent
- Optionally launches your agent
What the skill does
The /adopt-better-result command guides your AI agent through:
- Converting try/catch to Result.try/tryPromise
- Defining TaggedError classes for domain errors
- Refactoring to generator composition
- Migrating null checks to Result types
Supported agents
| OpenCode | .opencode/ | .opencode/skill/better-result-adopt/ |
| Claude | .claude/, CLAUDE.md | .claude/skills/better-result-adopt/ |
| Codex | .codex/, AGENTS.md | .codex/skills/better-result-adopt/ |
Manual usage
If you prefer not to use the interactive CLI:
npm install better-result
npx opensrc better-result
License
MIT