# better-result
> Typed, composable error handling for TypeScript with Result values, tagged errors, and generator composition.
# A better Result type for TypeScript
Source: https://better-result.dev/
TypeScript-firstzero runtime dependencies
`better-result` makes expected failure explicit without turning your application into nested conditionals. A value is either `Ok` or `Err`, and TypeScript carries both possibilities to the place where you decide what to do.
```ts
import { Result, TaggedError } from "better-result";
class InvalidPort extends TaggedError("InvalidPort")<{
input: string;
message: string;
}> {}
const parsePort = (input: string) => {
const port = Number(input);
return Number.isInteger(port) && port > 0
? Result.ok(port)
: Result.err(new InvalidPort({ input, message: "Port must be a positive integer" }));
};
const address = parsePort("3000").map((port) => `http://localhost:${port}`);
// Result
```
Success and error types stay visible through every transformation.
Use `yield*` to write multi-step workflows without callback pyramids.
Expected failures are `Err`; thrown callback defects become `Panic`.
## Start in sixty seconds
1. **Install**
```sh
npm install better-result
```
2. **Create a Result**
```ts
const parsed = Result.try(() => JSON.parse(input));
// Result
```
3. **Handle both branches**
```ts
const message = parsed.match({
ok: (value) => `Parsed ${JSON.stringify(value)}`,
err: (error) => `Could not parse: ${error.message}`,
});
```
## Choose your path
Learn the model, boundaries, and difference between recoverable errors and defects.
Build a small typed workflow and see the inferred error union.
Browse every `Result` constructor and combinator.
Migrate tagged errors, serialization, recovery, matching, and retries.
## One contract for people and tools
Every page is also emitted as clean Markdown. The site publishes [`/llms.txt`](/llms.txt), [`/llms-full.txt`](/llms-full.txt), page-level `.md` mirrors, searchable headings, and literal API names. Humans get navigation and examples; coding agents get the same technical contract without scraping presentation markup.
> **The shortest useful rule**
>
> Return `Err` for a failure the caller can reasonably handle. Let `Panic` expose bugs and broken
> invariants.
---
# Async operations and retries
Source: https://better-result.dev/core/async-and-retries
For application workflows, prefer [`Result.gen` with `Result.await`](/core/generator-composition#asynchronous-workflows). For a short `Promise>` pipeline, chain [static async combinators](/core/transforming-and-chaining) with `.then(Result.andThenAsync(...))`. This page focuses on safely creating those asynchronous Results and controlling retries.
## Capture a Promise
```ts
const result = await Result.tryPromise({
try: () => fetch(url),
catch: (cause) => new RequestFailed({ url, cause, message: "Request failed" }),
});
```
The attempt context always includes `attempt`, starting at 1. Its optional `signal` is the exact signal supplied in the top-level configuration; when the caller supplies none, `signal` is `undefined`. `Result.tryPromise` does not create an internal abort signal.
## Handle fulfilled HTTP errors
`fetch` rejects for network and cancellation failures, not for HTTP error statuses. Check fulfilled responses explicitly:
```ts
class HttpResponseError extends TaggedError("HttpResponseError")<{
status: number;
url: string;
message: string;
}> {}
const successfulResponse = await Result.tryPromise({
try: () => fetch(url),
catch: (cause) => new NetworkError({ url, cause, message: "Network request failed" }),
}).then(
Result.andThen((response: Response) =>
response.ok
? Result.ok(response)
: Result.err(
new HttpResponseError({
status: response.status,
url: response.url,
message: `Request failed with status ${response.status}`,
}),
),
),
);
// Result
```
A retry configured on `Result.tryPromise` retries rejected attempts. If selected HTTP statuses are also retryable, convert and retry them in an application operation whose policy explicitly covers status responses.
## Static retry policy
```ts
const result = await Result.tryPromise(callApi, {
retry: {
times: 3,
delayMs: 100,
backoff: "exponential",
},
});
```
`times` counts retries after the initial attempt. Static policies require `delayMs` and one of:
| Backoff | Delay before retry number `n` |
| ------------- | ----------------------------- |
| `constant` | `delayMs` |
| `linear` | `delayMs × n` |
| `exponential` | `delayMs × 2^(n - 1)` |
## Retry selected errors
```ts
retry: {
times: 3,
delayMs: 200,
backoff: "exponential",
shouldRetry: (error, { attempt }) =>
error._tag === "RateLimited" && attempt < 4,
}
```
`shouldRetry` is synchronous. If it throws, `Result.tryPromise` throws `Panic`.
## Error-dependent delays
```ts
retry: {
times: 3,
shouldRetry: (error) => error.retryable,
delayMs: (error, { attempt }) => error.retryAfterMs ?? attempt * 250,
}
```
A dynamic `delayMs` is the final delay and cannot be combined with `backoff` or `jitter`. A throwing callback becomes `Panic`.
## Jitter
```ts
retry: {
times: 3,
delayMs: 100,
backoff: "exponential",
jitter: 0.3,
}
```
A numeric jitter from `0` through `1` shortens a static delay by up to that fraction. `true` means full jitter. Invalid, non-finite, or out-of-range numbers throw `Panic` before the first attempt.
## Cancellation
```ts
const controller = new AbortController();
const result = await Result.tryPromise(({ signal }) => fetch(url, { signal }), {
signal: controller.signal,
retry: { times: 3, delayMs: 100, backoff: "constant" },
});
```
Aborting stops a pending retry delay and prevents later attempts. The active operation is cancelled only if your callback forwards `signal` to an abort-aware API. The latest typed `Err` is returned when retry scheduling stops.
> **Retries need an idempotency decision**
>
> Do not retry a state-changing operation merely because the API allows it. Decide whether the
> operation and its transport are safe to repeat.
---
# Collections
Source: https://better-result.dev/core/collections
## `Result.all`
Collect all successes or return the first error in input order:
```ts
const result = Result.all([Result.ok(1), Result.ok("two"), Result.ok(true)] as const);
// Result<[number, string, boolean], never>
```
```ts
const result = Result.all([loadUser(), loadTeam(), loadPlan()]);
// first Err short-circuits collection
```
The returned error is the first error encountered while iterating.
## `Result.allAsync`
Await all inputs concurrently, then collect in input order:
```ts
const result = await Result.allAsync([
fetchUser(id),
fetchTeam(teamId),
Result.ok(cachedPolicy),
] as const);
```
A raw rejected input Promise is a broken contract and becomes `Panic`. Promise-returning operations should normally catch expected rejection with `Result.tryPromise` before collection.
## `Result.partition`
Keep every branch and preserve relative order:
```ts
const [users, errors] = Result.partition(results);
// users: Array
// errors: Array
```
Unlike `all`, partition never short-circuits.
## `Result.partitionAsync`
```ts
const [users, errors] = await Result.partitionAsync(requests);
```
Inputs are awaited concurrently, then partitioned in input order. A rejected Promise becomes `Panic`.
## `Result.flatten`
```ts
const nested: ResultType, LoadError> = loadNested();
const flat = Result.flatten(nested);
// Result
```
Prefer `andThen` when creating the chain. `flatten` is useful when a nested Result already exists.
---
# Creating Results
Source: https://better-result.dev/core/creating-results
## `Result.ok`
```ts
const count = Result.ok(3); // Ok
const done = Result.ok(); // Ok
```
An `Ok` stores its payload in `.value` and has `status: "ok"`.
## `Result.err`
```ts
const missing = Result.err(new UserNotFound({ id }));
// Err
```
An `Err` stores its payload in `.error` and has `status: "error"`. Prefer meaningful error objects over strings at module boundaries.
## `Result.try`
Capture a synchronous throwing API:
```ts
const parsed = Result.try(() => JSON.parse(input));
// Result
```
Without a custom `catch`, the original thrown value is available as `UnhandledException.cause`.
Map the exception into your domain:
```ts
class InvalidJson extends TaggedError("InvalidJson")<{
cause: unknown;
message: string;
}> {}
const parsed = Result.try({
try: () => JSON.parse(input),
catch: (cause) => new InvalidJson({ cause, message: "Input is not valid JSON" }),
});
// Result
```
The callback receives `{ attempt }`. Synchronous retry runs immediately and is bounded by `times`:
```ts
const value = Result.try(({ attempt }) => readFlakyValue(attempt), {
retry: { times: 2 }, // one initial attempt, at most two retries
});
```
## `Result.tryPromise`
Capture a Promise-returning operation:
```ts
const response = await Result.tryPromise(() => fetch(url));
// Result
```
Use an object to create a typed error:
```ts
const response = await Result.tryPromise({
try: () => fetch(url),
catch: (cause) => new NetworkError({ cause, url, message: "Request failed" }),
});
```
Retries, delays, backoff, jitter, and cancellation are covered in [Async operations and retries](/core/async-and-retries).
> **Catch handlers must not fail**
>
> If a custom `catch` callback throws or rejects, better-result throws a `Panic`. A catch handler is
> the boundary that promised to convert an unknown exception into a known error.
---
# Extracting values
Source: https://better-result.dev/core/extracting-values
## Prefer `match` at handling boundaries
```ts
const response = result.match({
ok: (value) => Response.json(value, { status: 200 }),
err: (error) => toErrorResponse(error),
});
```
Both branches are visible and the output type is unified.
## Provide a fallback with `unwrapOr`
```ts
const port = parsePort(input).unwrapOr(3000);
// number
```
The fallback may have a different type, so the result is the union of the success and fallback types.
Static forms:
```ts
Result.unwrapOr(result, defaultValue);
Result.unwrapOr(defaultValue)(result);
```
## Assert success with `unwrap`
```ts
const value = result.unwrap();
const value = result.unwrap("Configuration must be valid at startup");
```
On `Err`, `unwrap` throws `Panic`; the error value is preserved as its cause. Use this only where an `Err` proves an invariant is broken or where an outer defect boundary intentionally terminates the operation.
## Do not unwrap routine failures
Avoid:
```ts
const user = findUser(id).unwrap(); // missing user is expected
```
Handle or propagate instead:
```ts
const greeting = findUser(id).map((user) => `Hello, ${user.name}`);
```
> **unwrap is an assertion**
>
> `unwrap` is not a substitute for error handling. Its meaning is: “an Err here is a defect.”
---
# Generator composition
Source: https://better-result.dev/core/generator-composition
`Result.gen` turns generator syntax into railway-style composition. `yield*` unwraps `Ok` values and stops at the first `Err`.
Prefer `Result.gen` for application workflows with several fallible steps. For asynchronous workflows, pair it with `Result.await` so intermediate values stay local and every yielded error remains in the inferred union.
## Synchronous workflows
```ts
const checkout = Result.gen(function* () {
const cart = yield* loadCart(cartId);
const stock = yield* reserveStock(cart.items);
const order = yield* createOrder(cart, stock);
return Result.ok(order);
});
// Result
```
Always return `Result.ok(...)` or `Result.err(...)` from the generator. Returning a bare value is a defect and throws `Panic`.
## Yield tagged errors directly
Every `TaggedError` is iterable, so guard clauses stay small:
```ts
const authorize = (user: User) =>
Result.gen(function* () {
if (!user.active) {
yield* new InactiveUser({ userId: user.id, message: "User is inactive" });
}
return Result.ok(user);
});
```
This is equivalent to `yield* Result.err(new InactiveUser(...))`.
## Asynchronous workflows
This is the preferred style for composing several `Promise>` operations. Use an async generator and wrap each Promise with `Result.await`:
```ts
const dashboard = await Result.gen(async function* () {
const session = yield* Result.await(readSession());
const user = yield* Result.await(fetchUser(session.userId));
const posts = yield* Result.await(fetchPosts(user.id));
return Result.ok({ user, posts });
});
```
Do not write `yield* await fetchUser()`. `Result.await` supplies the async iterable protocol needed by the generator and preserves the error type.
## Cleanup behavior
When an `Err` short-circuits, `Result.gen` closes the generator so `finally`, `Symbol.dispose`, and `Symbol.asyncDispose` cleanup can run. If generator body or cleanup code throws, `Result.gen` throws `Panic`.
```ts
const result = Result.gen(function* () {
using resource = openResource();
return Result.ok(yield* readResource(resource));
});
```
## Normalize an error union
Translate the completed workflow when callers should see one abstraction-level error:
```ts
const result = checkout.mapError(
(cause) => new CheckoutFailed({ stage: cause._tag, cause, message: "Checkout failed" }),
);
```
> **Keep generators declarative**
>
> Put effects and validation in named Result-returning functions. The generator should read as a
> workflow, not contain every implementation detail.
---
# Narrowing and matching
Source: https://better-result.dev/core/narrowing-and-matching
## Discriminant narrowing
```ts
if (result.status === "ok") {
result.value;
} else {
result.error;
}
```
`status` is the serializable discriminant: `"ok"` or `"error"`.
## Static guards
```ts
if (Result.isOk(result)) {
use(result.value);
}
if (Result.isError(result)) {
report(result.error);
}
```
## Instance guards
```ts
if (result.isOk()) {
use(result.value);
}
if (result.isErr()) {
report(result.error);
}
```
`isErr()` is the instance spelling; `Result.isError()` is the static spelling.
## `match`
Use `match` when both branches produce the same output type and you are ready to leave `Result`:
```ts
const statusCode = result.match({
ok: () => 200,
err: (error) => (error._tag === "NotFound" ? 404 : 500),
});
```
Static data-first and data-last forms are also available:
```ts
Result.match(result, {
ok: (value) => value.name,
err: () => "Anonymous",
});
const displayName = Result.match({
ok: (value: User) => value.name,
err: (_error: FindUserError) => "Anonymous",
});
displayName(result);
```
## Match Result first, then tagged errors
```ts
const response = result.match({
ok: (user) => ({ status: 200, body: user }),
err: (error) =>
error.match({
UserNotFound: () => ({ status: 404, body: null }),
DatabaseUnavailable: () => ({ status: 503, body: null }),
}),
});
```
This keeps the two decisions explicit: Result branch first, error variant second.
> **Thrown handlers are defects**
>
> A throwing `match` handler becomes `Panic`; return a value or a Result from a different combinator
> instead of throwing expected failures.
---
# Observing Results
Source: https://better-result.dev/core/observing
Observation methods return the original Result unchanged.
## Observe success
```ts
const result = parsePayload(input).tap((payload) => {
metrics.increment("payload.parsed");
logger.debug("Parsed payload", { id: payload.id });
});
```
Use `tapAsync` for an async side effect.
## Observe errors
```ts
const result = parsePayload(input).tapError((error) => {
metrics.increment("payload.parse_failed", { tag: error._tag });
});
```
Use `tapErrorAsync` for an async side effect.
## Observe either branch
```ts
const result = loadUser(id).tapBoth({
ok: (user) => logger.info("Loaded user", { userId: user.id }),
err: (error) => logger.warn("Could not load user", { tag: error._tag }),
});
```
For a `Promise>`, chain the static async combinator instead of awaiting only to call an instance method:
```ts
const observed = await fetchUser(userId).then(
Result.tapBothAsync({
ok: (user: User) => trace("user.loaded", { userId: user.id }),
err: (error: FetchUserError) => trace("user.load_failed", { tag: error._tag }),
}),
);
```
`tapBothAsync` selects one Promise-returning handler and preserves the original Result.
## Static and data-last forms
```ts
Result.tapError(result, reportError);
const withErrorReporting = Result.tapError((error: AppError) => reportError(error));
const observed = withErrorReporting(result);
```
## Safety contract
A throwing or rejected observer becomes `Panic`. Observation should not be able to quietly change the Result's error type. If telemetry failure is expected and recoverable, model that telemetry operation separately rather than hiding it in `tap`.
> **Keep payloads safe**
>
> A typed error may still contain secrets or personal data. Select fields deliberately before
> sending errors or Result values to logs and tracing systems.
---
# Transforming and chaining
Source: https://better-result.dev/core/transforming-and-chaining
## Transform success with `map`
```ts
const name = findUser(id).map((user) => user.name);
// Result
```
`map` runs only for `Ok`. Returning a Result from `map` nests it; use `andThen` to flatten a Result-returning callback.
## Transform errors with `mapError`
```ts
const user = queryUser(id).mapError(
(error) => new LoadUserFailed({ cause: error, message: "Could not load user" }),
);
// Result
```
Use this at abstraction boundaries to translate lower-level errors into the vocabulary the caller owns.
## Chain with `andThen`
```ts
const result = parseInput(raw).andThen(validateInput).andThen(saveInput);
// Result
```
`andThen` runs only for `Ok` and unions the existing and next error types. Use the static `Result.andThenAsync` when the next operation returns a Promise of Result:
```ts
const result = await Result.andThenAsync(parseInput(raw), (input) => saveInput(input));
```
## Compose `Promise` pipelines
When an operation already returns `Promise>`, Promise chaining composes with static, data-last combinators:
```ts
const postCount = await fetchUser(userId)
.then(Result.andThenAsync((user: User) => fetchPosts(user.id)))
.then(Result.map((posts: ReadonlyArray) => posts.length));
// Result
```
`Promise.then` unwraps each outer Promise. `Result.andThenAsync` runs the next asynchronous operation only for `Ok`, while `Result.map` transforms the eventual success. Both error types remain visible.
Use this form for a short pipeline. Prefer [`Result.gen` with `Result.await`](/core/generator-composition#asynchronous-workflows) when a workflow has several named steps or needs local intermediate values.
## Recover with `tryRecover`
Recovery runs only for `Err` and must return a Result:
```ts
const user = findUser(id).tryRecover((error) =>
error._tag === "UserNotFound" ? Result.ok(guestUser) : Result.err(error),
);
```
Recovery may introduce a different success type. The existing success is preserved:
```ts
const recovered = loadRemoteConfig().tryRecover(() => Result.ok(defaultConfigPath));
// Result
```
Use `tryRecoverAsync` when recovery returns `Promise>`:
```ts
const user = await fetchUser(userId).then(
Result.tryRecoverAsync(async (error: FetchUserError) =>
error._tag === "NetworkUnavailable" ? await readCachedUser(userId) : Result.err(error),
),
);
// Result
```
The async callback still returns a Result. Rejection is a callback defect and becomes `Panic`; capture expected Promise failure inside the recovery operation.
## Static and pipeable forms
Most combinators support data-first and data-last calls:
```ts
Result.map(result, (value) => value.id);
Result.map((value: User) => value.id)(result);
Result.andThen(result, validateUser);
Result.andThen(validateUser)(result);
```
This supports method chains, direct calls, and functional pipelines without separate APIs.
## Callback safety
Callbacks passed to mapping, chaining, and recovery combinators are assumed not to throw. If one does, the operation throws a `Panic` with the original exception as `cause`.
---
# Matching errors
Source: https://better-result.dev/errors/matching-errors
## Exhaustive instance matching
Every `TaggedError` instance has a `.match()` method:
```ts
const message = error.match({
UserNotFound: (error) => `No user ${error.userId}`,
PermissionDenied: (error) => `Missing permission ${error.permission}`,
});
```
Every `_tag` in the receiver union needs a handler. Handler parameters narrow to their exact error class. Different handler outputs produce a return union.
```ts
const outcome = error.match({
UserNotFound: () => 404,
PermissionDenied: () => "forbidden" as const,
});
// number | "forbidden"
```
This composes directly inside a `Result` error branch without annotating the inferred error:
```ts
const response = result.match({
ok: (user) => ({ status: 200, body: user }),
err: (error) =>
error.match({
UserNotFound: () => ({ status: 404, body: null }),
DatabaseUnavailable: () => ({ status: 503, body: null }),
}),
});
```
## Standalone `matchError`
Use `matchError(error, handlers)` when working with structurally tagged errors that were not created by `TaggedError`:
```ts
const message = matchError(error, {
UserNotFound: (error) => `No user ${error.userId}`,
PermissionDenied: (error) => `Missing permission ${error.permission}`,
});
```
The standalone function has the same exhaustive narrowing, return inference, and defect behavior as `.match()`. If the selected exhaustive handler throws, `.match()` and `matchError` throw `Panic` with the original exception as `cause`.
`match` is a reserved `TaggedError` instance name. The factory rejects payload property types named `match`, preventing constructor assignment from shadowing the method.
## Data-last matching
Annotate variant-specific parameters when the error union is not known until application:
```ts
const toMessage = matchError({
UserNotFound: (error: UserNotFound) => `No user ${error.userId}`,
PermissionDenied: (error: PermissionDenied) => error.message,
});
const message = toMessage(error);
```
## Partial matching with identity fallback
`matchErrorPartial` returns unhandled errors unchanged by default:
```ts
const transformed = matchErrorPartial(error, {
UserNotFound: (error) => `No user ${error.userId}`,
});
// string | PermissionDenied
```
This makes incremental handling explicit instead of discarding the rest of the union.
## Custom fallback
```ts
const message = matchErrorPartial(
error,
{ UserNotFound: (error) => `No user ${error.userId}` },
(unhandled) => `Unexpected failure: ${unhandled.message}`,
);
```
## Recover selected variants
Pass `Result.err` as the fallback to preserve unhandled variants while recovering one:
```ts
const recovered = result.tryRecover(
matchErrorPartial(
{
UserNotFound: (_error: UserNotFound) => Result.ok(guestUser),
},
Result.err,
),
);
```
## Match at boundaries
Keep domain functions returning typed errors. Match where the application can choose an HTTP status, CLI exit code, user message, retry policy, or compensating action.
---
# Panic and defects
Source: https://better-result.dev/errors/panic-and-defects
`Panic` represents an unrecoverable defect: user-supplied code broke a combinator contract or an asserted invariant failed. It is thrown, not returned as `Err`.
## Why Panic exists
If `.map()` silently converted every thrown callback into `Err`, `Result` would stop describing expected failures accurately. Panic keeps domain errors typed and defects loud.
## Common Panic sources
- callbacks passed to `map`, `mapError`, `andThen`, recovery, Result or TaggedError `match`, `matchError`, or observation throw;
- async callbacks reject;
- `Result.gen` body or cleanup throws;
- a generator returns a non-Result value;
- `unwrap()` sees `Err`;
- a retry predicate or dynamic delay throws;
- retry jitter is invalid;
- an input Promise to `allAsync` or `partitionAsync` rejects;
- a codec schema throws or rejects instead of returning validation issues;
- `serializeUnsafe` receives `ResultSerializationError`, or `deserializeUnsafe` receives `ResultDeserializationError`.
## Detect and report
```ts
import { isPanic, Panic } from "better-result";
try {
runApplication();
} catch (error) {
if (isPanic(error)) {
reportDefect(error.message, error.cause);
throw error;
}
}
```
Equivalent guards:
```ts
Panic.is(value);
isPanic(value);
value instanceof Panic;
```
`Panic` has `_tag: "Panic"`, `message`, `cause`, stack chaining, and `toJSON()`.
## Throw one deliberately
```ts
import { panic } from "better-result";
const unreachable = (value: never): never => panic("Unexpected application state", value);
```
Use this for impossible states and failed invariants, not ordinary validation.
## Boundary guidance
Catch Panic only at a true defect boundary—process entry point, request crash reporter, worker supervisor, or test assertion. Log/report it with appropriate redaction, then usually let the current operation fail.
> **Do not turn Panic back into a generic Err**
>
> Broadly catching Panic and returning `Err("something went wrong")` hides defects and makes typed
> error contracts misleading.
---
# Tagged errors
Source: https://better-result.dev/errors/tagged-errors
`TaggedError` creates real `Error` subclasses with a literal `_tag` and typed properties.
## Define an error
```ts
import { TaggedError } from "better-result";
class UserNotFound extends TaggedError("UserNotFound")<{
userId: string;
message: string;
}> {}
const error = new UserNotFound({
userId: "usr_123",
message: "User usr_123 was not found",
});
```
The class has normal `Error` behavior plus:
- `name === "UserNotFound"`;
- `_tag === "UserNotFound"` as a string literal;
- readonly `userId` and other declared properties;
- `toJSON()`;
- exhaustive `.match()` by `_tag`;
- `UserNotFound.is(value)`;
- iterator support for `yield*`.
## Match an error union
```ts
const message = error.match({
UserNotFound: (error) => `No user ${error.userId}`,
PermissionDenied: (error) => `Missing permission ${error.permission}`,
});
```
The handler map must cover every variant in the receiver's error union. Each handler receives its concrete error subtype. If the selected handler throws, `.match()` throws `Panic` with that exception as its cause. Use the standalone `matchError` function for structurally tagged errors or data-last matching.
`match` is reserved for this method. A TaggedError payload property or incompatible subclass member named `match` is rejected by TypeScript.
## Add a computed message
```ts
class RequestFailed extends TaggedError("RequestFailed")<{
url: string;
status: number;
message: string;
}> {
constructor(args: { url: string; status: number }) {
super({
...args,
message: `Request to ${args.url} failed with ${args.status}`,
});
}
}
```
## Preserve a cause
Declare `cause` in the property type and pass it to `super`:
```ts
class ParseFailed extends TaggedError("ParseFailed")<{
input: string;
cause: unknown;
message: string;
}> {}
new ParseFailed({ input, cause, message: "Could not parse input" });
```
Native `Error.cause` is populated. `toJSON()` serializes an `Error` cause to its name, message, and stack.
## Guards
```ts
if (UserNotFound.is(value)) {
value.userId;
}
if (TaggedError.is(value)) {
value._tag;
value.toJSON();
}
if (isTaggedError(value)) {
value._tag;
}
```
`TaggedError.is` and `isTaggedError` detect any better-result tagged error. A concrete class's `.is` guard detects that class.
## Yield directly
```ts
const result = Result.gen(function* () {
if (!user) {
yield* new UserNotFound({ userId, message: "User not found" });
}
return Result.ok(user);
});
```
## Public helper types
- `TaggedErrorClass` describes the factory's generic class.
- `TaggedErrorInstance` describes an instance structurally.
- `AnyTaggedError` describes any tagged error with `toJSON()`.
> **Current class syntax**
>
> The class extends `TaggedError("Tag")` directly. There is no trailing `()` after the property type.
---
# Installation
Source: https://better-result.dev/getting-started/installation
## Requirements
- TypeScript 5.4 or newer is required.
- The package is ESM-only.
- It has no runtime dependencies and does not require a specific server or browser runtime.
```sh npm
npm install better-result
```
```sh pnpm
pnpm add better-result
```
```sh Bun
bun add better-result
```
```sh Yarn
yarn add better-result
```
## Import values and types
```ts
import {
Result,
TaggedError,
matchError,
type Result as ResultType,
type InferOk,
type InferErr,
} from "better-result";
```
`Result` is both the namespace-like object that contains constructors/combinators and the name of the union type. Alias the type to `ResultType` when a file uses both heavily.
```ts
const findUser = (id: string): ResultType => {
// ...
};
```
## Runtime support
`better-result` uses standard JavaScript classes, generators, async generators, `AbortSignal`, and ESM. Your runtime or build target must support the specific feature you use. Generator composition requires generator support; retry cancellation requires `AbortController`/`AbortSignal`.
## Verify the install
```ts
import { Result } from "better-result";
const answer = Result.ok(42);
if (answer.status === "ok") {
console.log(answer.value);
}
```
Run your normal type-checker. No plugin, transform, or global type declaration is required.
> **Tree-shaking and package shape**
>
> The package exposes one ESM entry point. Import public symbols from `better-result`; internal file
> paths are not part of the supported API.
---
# Mental model
Source: https://better-result.dev/getting-started/mental-model
A `Result` is a discriminated union:
```ts
class Ok {
readonly status = "ok";
readonly value: T;
}
class Err {
readonly status = "error";
readonly error: E;
}
type Result = Ok | Err;
```
The actual classes carry methods and iterator behavior, but these three fields are the heart of the model.
## Expected failures are values
Use `Err` when a caller can make a meaningful decision:
- input is invalid;
- a record is absent;
- credentials are rejected;
- an upstream service is unavailable;
- serialization input does not satisfy a schema.
The caller sees `E` in the return type and cannot accidentally use the success value first.
## Defects are not domain errors
A callback that unexpectedly throws inside `.map()`, `.andThen()`, `.match()`, recovery, observation, a generator, or codec validation becomes or throws a `Panic`. A defect should be reported and fixed, not silently widened into `E | unknown`.
```ts
const value = Result.ok(2).map(() => {
throw new Error("broken invariant");
});
// throws Panic
```
See [Panic and defects](/errors/panic-and-defects) for the exact boundary.
## Branches narrow normally
```ts
const result: ResultType = findUser(id);
if (result.status === "ok") {
result.value; // User
} else {
result.error; // FindUserError
}
```
You can also use `Result.isOk`, `Result.isError`, `.isOk()`, `.isErr()`, or `.match()`.
## Transform the branch you own
| Operation | Runs on | Purpose |
| ------------------ | --------------- | -------------------------------------------- |
| `map` | `Ok` | Change a success value |
| `mapError` | `Err` | Change an error value |
| `andThen` | `Ok` | Continue with another Result-returning step |
| `tryRecover` | `Err` | Recover or replace the error |
| `tap` / `tapError` | selected branch | Observe without changing the Result |
| `match` | both | Leave the Result abstraction with one output |
## Put Result at useful boundaries
A useful Result boundary has a caller that can act on the error. Good examples are repositories, parsers, domain operations, adapters, and application workflows. Do not wrap every pure getter or turn programmer mistakes into recoverable variants.
> **Design from the caller**
>
> Start by naming what the caller can do after each failure. That usually reveals the right tagged
> error variants and the right boundary.
## Composition preserves evidence
`andThen` and `Result.gen` union errors from every step. The final signature is a compact ledger of all expected failures:
```ts
const loadDashboard = (): ResultType =>
Result.gen(function* () {
const session = yield* readSession();
const user = yield* findUser(session.userId);
const dashboard = yield* queryDashboard(user.id);
return Result.ok(dashboard);
});
```
Handle or translate that union at the next meaningful boundary.
---
# Quickstart
Source: https://better-result.dev/getting-started/quickstart
This example parses an environment value, validates it, and builds a server address without throwing expected failures.
## 1. Define errors callers can distinguish
```ts
import { Result, TaggedError, type Result as ResultType } from "better-result";
class MissingEnv extends TaggedError("MissingEnv")<{
name: string;
message: string;
}> {}
class InvalidPort extends TaggedError("InvalidPort")<{
input: string;
message: string;
}> {}
```
The `_tag` field is a string literal, so unions narrow without `instanceof` coupling.
## 2. Return Results from fallible operations
```ts
const readEnv = (name: string): ResultType => {
const value = process.env[name];
return value === undefined
? Result.err(new MissingEnv({ name, message: `${name} is required` }))
: Result.ok(value);
};
const parsePort = (input: string): ResultType => {
const port = Number(input);
return Number.isInteger(port) && port > 0 && port <= 65_535
? Result.ok(port)
: Result.err(new InvalidPort({ input, message: "Expected a port from 1 to 65535" }));
};
```
## 3. Compose with `yield*`
```ts
const readServerAddress = () =>
Result.gen(function* () {
const host = yield* readEnv("HOST");
const portText = yield* readEnv("PORT");
const port = yield* parsePort(portText);
return Result.ok(`http://${host}:${port}`);
});
// Result
```
The first `Err` short-circuits. Every `Ok` is unwrapped. The error type is inferred from what the generator yielded.
## 4. Handle the complete result
```ts
const exitCode = readServerAddress().match({
ok: (address) => {
console.log(`Listening at ${address}`);
return 0;
},
err: (error) => {
console.error(
error.match({
MissingEnv: (missing) => `Configuration missing: ${missing.name}`,
InvalidPort: (invalid) => `Invalid PORT ${JSON.stringify(invalid.input)}`,
}),
);
return 1;
},
});
```
Adding a new tagged error to this workflow makes the exhaustive `.match()` handlers fail to type-check until you decide how to present it.
## Next steps
Learn sync, async, direct tagged-error yields, and cleanup behavior.
Add constructors, metadata, JSON output, and guards.
---
# Documentation for agents
Source: https://better-result.dev/guides/agents
This site is built for browser readers and plain-text-searching coding agents from the same source.
## Machine-readable routes
| Route | Use |
| ------------------------- | -------------------------------- |
| `/agents.txt` | Compact documentation index |
| `/llms.txt` | Alias-compatible compact index |
| `/llms-full.txt` | Full documentation corpus |
| `/.md` | Clean Markdown for one page |
| `/.mdx` | Original MDX source for one page |
| `/agent-readability.json` | Generated readability metadata |
Codex probes `/agents.txt` directly, while clients that follow the `llms.txt` convention can use `/llms.txt`. Every HTML page also advertises the compact index with a `rel="alternate"` plain-text link.
Point an agent at the smallest source that answers its question. A focused page costs less context than the full corpus.
## Portable skills
The repository ships two agent skills:
- [`adopt-better-result`](https://github.com/dmmulroy/better-result/tree/3.0/skills/adopt-better-result) audits a TypeScript repository or migrates one named vertical slice.
- [`migrate-better-result-3`](https://github.com/dmmulroy/better-result/tree/3.0/skills/migrate-better-result-3) guides migration from older better-result APIs.
Install with skills.sh-compatible tooling:
```sh
npx skills add dmmulroy/better-result@adopt-better-result
npx skills add dmmulroy/better-result@migrate-better-result-3
```
## Prompting contract
Useful agent instructions name the boundary and desired behavior:
```text
Use better-result for the createUser vertical slice. Return tagged domain errors
from validation and persistence, compose them with Result.gen, and exhaustively
map them to the existing HTTP response contract. Do not convert defects to Err.
```
Avoid asking an agent to “Result-ify everything.” Result boundaries should correspond to caller decisions.
## Search vocabulary
Use literal public names in searches and prompts:
- `Result.tryPromise` for exceptions, retries, jitter, and abort signals;
- `Result.gen` and `Result.await` for generator composition;
- `TaggedError#match`, `matchError`, and `matchErrorPartial` for error unions;
- `Result.codec` for validated transport/persistence boundaries; `serializeUnsafe` and `deserializeUnsafe` opt into `Panic` for codec validation errors;
- `Panic`, `isPanic`, and `panic` for defects;
- `Result.all`, `allAsync`, `partition`, and `partitionAsync` for collections.
**Agent implementation checklist**
1. Inspect the installed package declarations or repository source before editing.
2. Inventory each expected failure from source to handling boundary.
3. Define tagged error variants in the owning domain vocabulary.
4. Keep unknown exceptions at adapters and translate them with `Result.try` or `Result.tryPromise`.
5. Compose Results without `unwrap()` in ordinary control flow.
6. Handle the complete inferred error union at a policy boundary.
7. Test each branch and run formatting, lint, type-checking, tests, and build.
---
# Application patterns
Source: https://better-result.dev/guides/application-patterns
## Parse at the edge
Turn untrusted input into domain values once:
```ts
const parseCreateUser = (input: unknown): ResultType => {
const parsed = CreateUserSchema.safeParse(input);
return parsed.success
? Result.ok(parsed.data)
: Result.err(new InvalidCreateUser({ issues: parsed.error.issues, message: "Invalid user" }));
};
```
Inner code should receive meaningful values instead of repeatedly checking unknown shapes.
## One error vocabulary per boundary
A repository may expose `UserNotFound | UserStoreUnavailable`, while its database adapter knows driver-specific exceptions. Translate once:
```ts
const findUser = (id: UserId) =>
queryUserRow(id).mapError((cause) =>
cause._tag === "NoRows"
? new UserNotFound({ id, message: "User not found" })
: new UserStoreUnavailable({ cause, message: "User store unavailable" }),
);
```
Do not leak framework, database, or HTTP error types through domain APIs.
## Compose in an application workflow
```ts
const registerUser = (input: unknown) =>
Result.gen(function* () {
const command = yield* parseCreateUser(input);
yield* ensureEmailAvailable(command.email);
const user = yield* insertUser(command);
yield* publishUserRegistered(user);
return Result.ok(user);
});
```
The signature records every expected failure. Handle or normalize the union where the caller's policy lives.
## Map to transport once
```ts
const toHttpResponse = (result: ResultType) =>
result.match({
ok: (user) => Response.json(user, { status: 201 }),
err: (error) =>
error.match({
InvalidCreateUser: (error) => Response.json(error.toJSON(), { status: 400 }),
EmailTaken: (error) => Response.json(error.toJSON(), { status: 409 }),
UserStoreUnavailable: () => Response.json({ message: "Try again" }, { status: 503 }),
PublishFailed: () => Response.json({ message: "Try again" }, { status: 503 }),
}),
});
```
## Avoid common traps
Strings are hard to narrow, enrich, serialize deliberately, and match exhaustively. Prefer
tagged classes at module boundaries.
Pure, total helpers do not need Result. Introduce Result where failure is part of the caller's
decision.
Panic is a defect signal. Catch it at reporting/supervision boundaries, not in ordinary domain
control flow.
`unwrap()` converts `Err` into a thrown defect. Compose or handle instead unless the Err truly
violates an invariant.
---
# Testing Result code
Source: https://better-result.dev/guides/testing
## Assert the discriminated shape
```ts
const result = parsePort("3000");
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value).toBe(3000);
}
```
Narrow before accessing payloads so tests follow the same contract as production callers.
## Test each error variant
```ts
const result = parsePort("nope");
expect(result.status).toBe("error");
if (result.status === "error") {
expect(InvalidPort.is(result.error)).toBe(true);
expect(result.error.input).toBe("nope");
}
```
For a workflow, cover every tagged variant the public signature promises—not only a generic failure assertion.
## Test short-circuit behavior
```ts
const save = vi.fn(() => Result.ok(undefined));
const result = Result.gen(function* () {
yield* Result.err(new InvalidInput({ message: "invalid" }));
yield* save();
return Result.ok(undefined);
});
expect(Result.isError(result)).toBe(true);
expect(save).not.toHaveBeenCalled();
```
## Test retry policy deterministically
Inject or faithfully control the operation's sequence. Assert attempt numbers and final Result. Keep delays tiny or use your test runner's fake timers.
```ts
const attempts: number[] = [];
const result = await Result.tryPromise(
async ({ attempt }) => {
attempts.push(attempt);
if (attempt < 3) throw new Error("temporary");
return "ready";
},
{ retry: { times: 2, delayMs: 0, backoff: "constant" } },
);
expect(attempts).toEqual([1, 2, 3]);
expect(Result.isOk(result)).toBe(true);
```
## Assert defects separately
```ts
expect(() =>
Result.ok(1).map(() => {
throw new Error("bug");
}),
).toThrow(Panic);
```
Do not make expected-error tests catch Panic; that blurs the contract you are trying to prove.
## Add compile-time tests
Inference is part of the API. Use your repository's type-test convention to verify important unions:
```ts
const result = Result.all([Result.ok(1), Result.err("failed" as const)] as const);
type Error = InferErr; // "failed"
```
---
# Migrate from 2.x
Source: https://better-result.dev/migration/from-2
Upgrade the dependency, then let the compiler inventory changed call sites:
```sh
bun add better-result@latest
bun run check
```
## TaggedError no longer has a trailing factory call
```ts
// Before
class NotFound extends TaggedError("NotFound")<{ id: string; message: string }>() {}
// Now
class NotFound extends TaggedError("NotFound")<{ id: string; message: string }> {}
```
`TaggedErrorClass` becomes `TaggedErrorClass`; payload typing is applied by the subclass.
## Replace unvalidated serialization helpers
`Result.serialize`, `Result.deserialize`, and `Result.hydrate` were removed. Define boundary-owned schemas:
```ts
const codec = Result.codec({
serialize: { ok: DomainToWireSchema, err: ErrorToWireSchema },
deserialize: { ok: DomainFromWireSchema, err: ErrorFromWireSchema },
});
const encoded = await codec.serialize(result);
const decoded = await codec.deserialize(input);
```
Handle `ResultSerializationError` and `ResultDeserializationError`. Schema async behavior controls operation async behavior. When you own both producer and consumer, version their schemas together, and treat contract mismatch as a defect, `serializeUnsafe` and `deserializeUnsafe` remove the codec-error handling layer and throw `Panic` instead. A valid decoded Err remains a domain Err. Keep safe methods at public, independently versioned, persisted, or otherwise untrusted boundaries.
## Recovery can widen success
```ts
const result: ResultType = findUser(id);
const recovered = result.tryRecover(() => Result.ok(guestId));
// Result
```
Do not cast away the widened union. Narrow it or return a common domain type.
## Matching infers handler return unions
`matchError` and `matchErrorPartial` retain different handler return types. `matchErrorPartial` may omit its fallback; unhandled variants then pass through unchanged.
```ts
const output = matchErrorPartial(error, {
NotFound: () => 404,
});
// 404 | unhandled error variants
```
## TaggedError instance matching
Tagged errors can now match their union directly. This is additive; existing `matchError` calls remain valid.
```ts
const response = result.match({
ok: (user) => ({ status: 200, body: user }),
err: (error) =>
error.match({
UserNotFound: () => ({ status: 404, body: null }),
DatabaseUnavailable: () => ({ status: 503, body: null }),
}),
});
```
Use `matchError` for structurally tagged errors or data-last matching. Both exhaustive forms turn a selected handler exception into `Panic` and preserve the exception as `cause`.
`match` is now a reserved TaggedError instance name. TypeScript rejects payload properties and incompatible subclass members with that name; rename any collision during migration.
## Collection additions
- `Result.all` preserves tuple success types and returns the first error.
- `Result.allAsync` awaits concurrently and preserves input-order error selection.
- `Result.partition` supports heterogeneous Results.
- `Result.partitionAsync` awaits concurrently and partitions both branches.
## Retry additions
Existing bounded static retry policies remain valid. Async retries also support:
- `TryPromiseContext.signal`;
- top-level `signal` cancellation;
- dynamic `delayMs(error, context)`;
- static `jitter`;
- `shouldRetry(error, context)`.
Dynamic delays cannot use `backoff` or `jitter`.
## Repository skill
For a systematic migration, use the repository's portable `migrate-better-result-3` skill. It inventories affected APIs, includes a safe TaggedError codemod, and treats the compiler as the migration ledger.
```sh
npx skills add dmmulroy/better-result@migrate-better-result-3
```
> **Keep behavior stable first**
>
> Apply mechanical syntax and codec changes, type-check, then adopt optional collection or retry
> features separately. This keeps migration failures attributable.
---
# Error API
Source: https://better-result.dev/reference/errors
## `TaggedError(tag)`
Factory for a generic Error subclass:
```ts
class NotFound extends TaggedError("NotFound")<{
id: string;
message: string;
}> {}
```
Instances expose `_tag`, declared properties, native Error fields, `toJSON()`, exhaustive `.match(handlers)`, and generator iterator behavior. Concrete classes expose `.is(value)`.
## Guards
| API | Narrows to |
| ------------------------- | --------------------- |
| `TaggedError.is(value)` | `AnyTaggedError` |
| `isTaggedError(value)` | `AnyTaggedError` |
| `ConcreteError.is(value)` | the concrete subclass |
| `Panic.is(value)` | `Panic` |
| `isPanic(value)` | `Panic` |
## Matching
| API | Behavior |
| ---------------------------------------------- | ---------------------------------------------------- |
| `error.match(handlers)` | Exhaustive instance match over every `_tag` |
| `matchError(error, handlers)` | Exhaustive standalone match over every `_tag` |
| `matchError(handlers)(error)` | Data-last exhaustive match |
| `matchErrorPartial(error, handlers)` | Handle selected tags; return others unchanged |
| `matchErrorPartial(error, handlers, fallback)` | Handle selected tags; transform others with fallback |
| `matchErrorPartial(handlers, fallback)(error)` | Data-last partial match |
Handler return types are inferred as a union when they differ. If an exhaustive `.match()` or `matchError` handler throws, the operation throws `Panic` with the original exception as `cause`. `matchErrorPartial` retains its fallback and handler exception behavior.
The `match` property name is reserved on `TaggedError` payloads; TypeScript also rejects incompatible subclass members with that name.
## Built-in error classes
### `UnhandledException`
Returned by `Result.try` and `Result.tryPromise` when no custom catch translator is supplied.
| Property | Type |
| --------- | ---------------------- |
| `_tag` | `"UnhandledException"` |
| `cause` | `unknown` |
| `message` | `string` |
### `ResultSerializationError`
Returned when a selected serialization schema reports validation issues. `serializeUnsafe` throws `Panic` with this error as its cause instead of returning it.
| Property | Type |
| -------- | ---------------------------------------- |
| `_tag` | `"ResultSerializationError"` |
| `value` | `unknown` |
| `issues` | optional readonly Standard Schema issues |
### `ResultDeserializationError`
Returned for an invalid serialized envelope or when a selected payload schema reports issues. `deserializeUnsafe` throws `Panic` with this error as its cause while preserving valid decoded domain Err values.
| Property | Type |
| -------- | ------------------------------------------------------------------------ |
| `_tag` | `"ResultDeserializationError"` |
| `value` | `unknown` |
| `issues` | optional readonly Standard Schema issues; absent for an invalid envelope |
### `Panic`
Thrown for defects. Exposes `_tag: "Panic"`, `message`, optional `cause`, `toJSON()`, static `.is`, and generator iterator behavior.
## `panic(message, cause?)`
Throws a new `Panic` and returns `never` at the type level.
---
# Exported types
Source: https://better-result.dev/reference/exported-types
All public types are imported from `better-result`.
## Result and inference
| Type | Meaning |
| ------------------ | ------------------------------------------------- |
| `Result` | `Ok \| Err` |
| `Ok` | success variant class |
| `Err` | error variant class |
| `InferOk` | extract the success type from a Result or variant |
| `InferErr` | extract the error type from a Result or variant |
```ts
const result = Result.gen(function* () {
const user = yield* findUser(id);
return Result.ok(user.name);
});
type Value = InferOk; // string
type Error = InferErr; // FindUserError
```
## Tagged errors
| Type | Meaning |
| --------------------------------- | ---------------------------------------------------- |
| `AnyTaggedError` | any tagged Error with `_tag` and `toJSON()` |
| `TaggedErrorClass` | generic class returned by the factory |
| `TaggedErrorInstance` | structural tagged error instance with readonly Props |
## Retry context
| Type | Fields |
| ------------------- | ------------------------------------------------- |
| `TryContext` | `attempt: number` (one-based) |
| `TryPromiseContext` | `attempt: number`, optional `signal: AbortSignal` |
Retry config itself is inferred from `Result.tryPromise`; it is not exported as a named type.
## Serialized envelopes
| Type | Shape |
| ------------------------ | ------------------------------------- |
| `SerializedOk` | `{ status: "ok"; value: T }` |
| `SerializedErr` | `{ status: "error"; error: E }` |
| `SerializedResult` | `SerializedOk \| SerializedErr` |
These describe wire envelopes, not proof of validation. Use `Result.codec` at untrusted boundaries.
## Codec types
| Type | Meaning |
| ------------------------ | ----------------------------------------------- |
| `ResultCodecConfig<...>` | four schemas for serialize/deserialize × ok/err |
| `ResultCodec<...>` | safe and unsafe codec operations |
| `ResultCodecIssue` | Standard Schema validation issue alias |
## Standard Schema types
| Type | Meaning |
| --------------------------------- | ----------------------------------- |
| `StandardSchemaV1` | supported Standard Schema interface |
| `StandardSchemaInput` | infer schema input |
| `StandardSchemaOutput` | infer schema output |
| `StandardSchemaIssue` | one validation issue |
| `StandardSchemaPathSegment` | validation path property wrapper |
| `StandardSchemaResult