better-result
better-result is an ESM-only TypeScript Result type for making expected failures part of a function's return type. It provides Ok and Err values, typed Error subclasses, mapping and recovery helpers, generator-based composition for multi-step work, Promise rejection capture, retries, collection helpers, and schema-validated serialization. Version 3 draws a firm line between errors callers can handle and unexpected defects, which it wraps in Panic instead of quietly adding unknown to every error union.
A thoughtful, batteries-included Result toolkit for TypeScript teams that deliberately model recoverable failures. Skip it if ESM-only packaging, a broad API, or a fresh 3.0 migration surface is more ceremony than your error boundaries justify.
Use it if
- You want TypeScript to force callers to handle validation, not-found, authentication, or upstream-service failures explicitly
- You have multi-step synchronous or asynchronous workflows and prefer linear generator composition over nested branching
- You need tagged Error subclasses with exhaustive matching and serializable payloads
- You want one Result library that also covers retries, collections, observation hooks, and Standard Schema transport boundaries
- Your project uses CommonJS or TypeScript older than 5.4: version 3 is ESM-only and documents TypeScript 5.4 as the minimum
- Your team is not willing to model expected error unions; adding Result to only a few arbitrary functions creates wrapping and unwrapping noise without a useful boundary
- You need a mature, slow-changing contract: version 3.0 changed TaggedError subclass syntax and replaced the old serialize, deserialize, and hydrate helpers with Result.codec
- You want the smallest possible Result abstraction: the package also defines Panic behavior, generators, retries, codecs, collection helpers, and a sizable method surface that developers must learn
- You expect Result.tryPromise to turn HTTP 4xx and 5xx responses into Err automatically; fetch fulfills those responses, and the README requires an explicit response.ok check
Setup reality
Installation is one package and there are zero runtime dependencies, but compatibility is the first gate: better-result 3.0 is ESM-only and requires TypeScript 5.4 or newer. CommonJS require calls are not an advertised path. The library works best after the team agrees which failures are expected and actionable, because those belong in Err, while thrown callback failures and broken invariants become Panic. That distinction affects logging, tests, and top-level supervision. The generator syntax is compact once learned, but asynchronous generators require Result.await around each Promise<Result>; yielding the Promise itself is not the documented form. Result.tryPromise catches rejected promises, not unsuccessful HTTP status codes, and cancellation only works when your operation forwards the provided signal. Retry times count attempts after the first call, so times: 3 can run the operation four times. For RPC or persistence you must bring a Standard Schema-compatible validation library and define four boundary-owned schemas for Result.codec. Upgrading from 2.x is real migration work: TaggedError lost its trailing factory call, match became reserved on tagged error instances, and the old serialization helpers were replaced. Types are bundled, so no separate @types package or generation step is needed.
Patterns
Create a Result and handle both branchescreate-and-match
import { Result } from "better-result";
const parsed = Number.isFinite(Number(input))
? Result.ok(Number(input))
: Result.err("not-a-number");
const message = parsed.match({
ok: (value) => `Value: ${value}`,
err: (error) => `Invalid input: ${error}`,
});match is the clearest exit from Result when both branches need an explicit policy.
Define and narrow a typed Error subclassdefine-tagged-error
import { TaggedError } from "better-result";
class UserNotFound extends TaggedError("UserNotFound")<{
userId: string;
message: string;
}> {}
const error = new UserNotFound({
userId: "u_42",
message: "User was not found",
});
if (UserNotFound.is(error)) console.log(error.userId);Version 3 has no trailing () after the TaggedError property type; that older 2.x syntax no longer applies.
Short-circuit a synchronous workflowcompose-generator
import { Result, TaggedError } from "better-result";
class EmptyName extends TaggedError("EmptyName")<{ message: string }> {}
const normalizeName = (input: string) => Result.gen(function* () {
const name = input.trim();
if (!name) yield* new EmptyName({ message: "Name is required" });
return Result.ok(name.toLowerCase());
});A tagged error can be yielded directly as a guard clause; the generator returns Err without throwing.
Compose Promise Results in ordercompose-async
import { Result } from "better-result";
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 });
});Wrap each Promise<Result> with Result.await; the first Err stops later operations and contributes to the inferred error union.
Translate a thrown value into a typed Errcapture-exception
import { Result, TaggedError } from "better-result";
class InvalidJson extends TaggedError("InvalidJson")<{
cause: unknown;
message: string;
}> {}
const decoded = Result.try({
try: () => JSON.parse(source) as unknown,
catch: (cause) => new InvalidJson({ cause, message: "Invalid JSON" }),
});Use the object form when unknown thrown values must be translated into a specific error contract.
Retry a cancellation-aware Promiseretry-promise
const response = await Result.tryPromise(
{
try: ({ signal }) => fetch(url, { signal }),
catch: (cause) => new NetworkError({ cause, url, retryable: true, message: "Request failed" }),
},
{
signal: controller.signal,
retry: { times: 3, delayMs: 100, backoff: "exponential", jitter: true },
},
);times is retries after the initial attempt, and aborting works only because the provided signal is forwarded to fetch.
Turn an unsuccessful fetch response into Errcheck-http-status
const checked = await response.then(
Result.andThen((res: Response) =>
res.ok
? Result.ok(res)
: Result.err(new HttpResponseError({
status: res.status,
url: res.url,
message: `HTTP ${res.status}`,
})),
),
);fetch resolves for 4xx and 5xx responses, so Result.tryPromise alone does not classify them as failures.
Recover one error variant with a fallbackrecover-selected-error
const userOrGuest = findUser(userId).tryRecover((error) =>
UserNotFound.is(error)
? Result.ok(guestUser)
: Result.err(error),
);Return the unhandled error unchanged so only the selected variant disappears from the resulting error union.
Require every Result in a tuple to succeedcollect-results
const context = Result.all([
loadCachedUser(userId),
loadCachedTeam(teamId),
loadCachedPlan(accountId),
] as const);The const assertion preserves tuple positions; Result.all returns the first error instead of accumulating every failure.
Keep all successes and all errorspartition-results
const validationResults = rows.map(validateImportRow);
const [validRows, invalidRowErrors] = Result.partition(validationResults);partition processes the full collection and preserves relative order in both output arrays.
Log either branch without changing itobserve-result
const observed = parseConfiguration(input).tapBoth({
ok: (config) => console.info("Configuration loaded", config),
err: (error) => console.error("Configuration rejected", error),
});Observers preserve the original Result, but an observer that throws is treated as a defect and becomes Panic.
Use a complete fallback policyextract-default
const port = parsePort(process.env.PORT ?? "").unwrapOr(3000);Use unwrapOr only when one fallback fully handles every Err; unwrap throws Panic and is meant for broken invariants.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| neverthrow | npm | Choose it for a widely adopted Result and ResultAsync API with a narrower conceptual model |
| oxide.ts | npm | Choose it when Rust-like Option and Result types are both central to your code style |
| ts-results-es | npm | Choose it for a small ESM Result and Option implementation with familiar Rust-inspired names |