better-result review
better-result 3.0.1 is a TypeScript Result library for functions that can return either an Ok value or a typed Err. It includes tagged Error subclasses, generator composition, Promise rejection capture, retry controls, collection helpers, and schema-checked serialization. The 3.0 line separates expected failures from defects by turning unexpected callback throws into Panic. Version 3.0.1 fixes inferred error unions when andThen or tryRecover callbacks introduce another error type. Our fresh install found no dependencies, bundled declarations, working import and require paths on Node 22, and a 3.3 KB gzipped browser bundle.
better-result 3.0.1 installed in 0.9 seconds with 0 dependencies, used 1 MB on disk, and produced a 3.3 KB gzipped bundle in our sandbox. It fits TypeScript 5.4+ teams that will model expected failures consistently; teams wanting only a two-variant container should pick a narrower Result package.
We installed it
| Install | ✓ · 0.9s | 1 package on disk · 1 MB |
| Import | ✓ | ESM import works · require() works · ESM package with exports map |
| Browser | 3.3 KB | gzipped (10.5 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does better-result install cleanly?
Yes. In a fresh container with an empty cache, npm install better-result finished in 0.9s, leaving 1 package and 1 MB on disk. npm audit reported no known vulnerabilities.
How much does better-result add to a browser bundle?
3.3 KB gzipped (10.5 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does better-result work with both ESM and CommonJS?
Yes. Both import 'better-result' and require('better-result') worked in Node 22 in our run. The package is published as ESM with an exports map.
Does better-result include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
better-result or neverthrow: which should you use?
neverthrow: Choose it for an established Result and ResultAsync API with many existing integrations and examples. better-result 3.0.1 installed in 0.9 seconds with 0 dependencies, used 1 MB on disk, and produced a 3.3 KB gzipped bundle in our sandbox.
When should you not use better-result?
Your project is pinned below TypeScript 5.4. The README names 5.4 as the minimum for the current 3.x API.
Use it if
- Your TypeScript service needs validation, missing-record, authorization, or upstream failures represented in return types rather than hidden behind throws.
- Several Result-returning steps must read like ordinary sequential code while preserving the full error union.
- Domain errors need real Error subclasses, literal tags, typed payloads, JSON output, and exhaustive matching.
- Results cross queues, RPC calls, or storage boundaries where Standard Schema validation can check both success and error payloads.
- Your project is pinned below TypeScript 5.4. The README names 5.4 as the minimum for the current 3.x API.
- Your CommonJS runtime predates Node's ability to load synchronous ESM through require. The package declares type module and exposes an import target, even though require worked in our Node 22 test.
- Callers cannot do anything useful with the modeled failures. Wrapping total helpers or unrecoverable defects in Result adds branches without creating a decision point.
- You want a tiny Ok and Err primitive. Panic rules, generators, retry scheduling, observers, codecs, and collection methods give this package a wider contract to teach and review.
- You are upgrading casually from 2.x. Version 3 removes the trailing TaggedError factory call, reserves match on tagged errors, and replaces serialize, deserialize, and hydrate with Result.codec.
Setup reality
We installed better-result 3.0.1 in a fresh Node 22 Bookworm sandbox. npm finished in 0.9 seconds and left 1 package using 1 MB. The package has 0 direct and 0 peer dependencies, occupies 284 KB unpacked, bundles TypeScript declarations, and produced a 10.5 KB minified browser bundle, 3.3 KB gzipped. npm audit reported 0 known vulnerabilities.
The package declares ESM through type module and has an exports map. Both ESM import and require worked on our Node 22 box, but the README still describes the package as ESM-only. Treat import as the supported path and test older CommonJS build chains before adopting it. TypeScript 5.4 is the documented floor. There is no credentials file, code generation step, native build, or peer package to configure.
The setup cost moves into error design. Expected failures belong in Err; throws inside mapping, matching, observers, generators, or codec validation become Panic. Catch Panic at an application supervision boundary instead of adding it to every routine error handler. Async generator workflows must wrap each Promise with Result.await. Result.tryPromise catches rejection, while a fulfilled fetch response with status 404 or 500 still needs an explicit response.ok check.
Retry configuration counts attempts after the initial call, so times: 3 permits 4 calls. Cancellation only reaches the operation when you pass the supplied AbortSignal into fetch or another cancellation-aware API. Result.allAsync starts inputs concurrently but reports the first error by input order. Result.codec also requires four Standard Schema-compatible boundary schemas; the package does not choose or install a validator for you.
Patterns
Return an explicit parse failure create-result
import { Result } from 'better-result';
function parsePort(input: string) {
const port = Number(input);
return Number.isInteger(port) && port > 0
? Result.ok(port)
: Result.err('invalid-port' as const);
}The return type retains both number and the literal invalid-port error, so callers must handle the failed parse.
Leave Result with one output type match-branches
const label = parsePort(input).match({
ok: (port) => `Listening on ${port}`,
err: (reason) => `Cannot start: ${reason}`,
});match requires policies for Ok and Err and produces one ordinary value after both branches agree on an output.
Create a typed Error variant define-tagged-error
import { TaggedError } from 'better-result';
class UserMissing extends TaggedError('UserMissing')<{
userId: string;
message: string;
}> {}
const error = new UserMissing({
userId: 'u_42',
message: 'User does not exist',
});Version 3 uses the class form shown here with no trailing factory call after the property type.
Stop a workflow at the first Err compose-sync
const checkout = (cartId: string) =>
Result.gen(function* () {
const cart = yield* findCart(cartId);
const reservation = yield* reserveStock(cart.items);
const receipt = yield* chargePayment(cart, reservation);
return Result.ok(receipt);
});Result.gen unwraps each Ok and returns the first Err while TypeScript unions errors from every yielded operation.
Await Result promises inside a generator compose-async
const dashboard = await Result.gen(async function* () {
const session = yield* Result.await(readSession());
const user = yield* Result.await(fetchUser(session.userId));
return Result.ok({ session, user });
});A Promise<Result> needs Result.await to provide the async iterator contract and preserve its error type.
Translate a rejected Promise capture-rejection
const response = Result.tryPromise({
try: ({ signal }) => fetch(url, { signal }),
catch: (cause) => new NetworkFailure({ cause, url, message: 'Request failed' }),
});Result.tryPromise catches rejection only; HTTP 4xx and 5xx responses fulfill fetch and require a separate response.ok check.
Retry with cancellation and backoff retry-request
const result = Result.tryPromise(
{ try: ({ signal }) => fetch(url, { signal }), catch: toNetworkFailure },
{ signal: controller.signal, retry: { times: 3, delayMs: 100, backoff: 'exponential', jitter: true } },
);times: 3 allows 3 retries after the first call, for at most 4 attempts; forwarding signal lets cancellation reach fetch.
Recover only a missing user recover-error
const user = findUser(userId).tryRecover((error) =>
UserMissing.is(error) ? Result.ok(guestUser) : Result.err(error),
);Returning unrelated errors unchanged removes only UserMissing from the resulting error union.
Require every cached value collect-all
const context = Result.all([
loadCachedUser(userId),
loadCachedTeam(teamId),
loadCachedPlan(accountId),
] as const);Result.all preserves tuple positions and stops at the first Err instead of collecting every failure.
Keep successful and failed rows partition-results
const checked = rows.map(validateImportRow);
const [validRows, rowErrors] = Result.partition(checked);Result.partition evaluates the full input and preserves relative order within both output arrays.
Record either branch without replacing it observe-result
const observed = loadAccount(id).tapBoth({
ok: (account) => metrics.loaded(account.id),
err: (error) => metrics.failed(error._tag),
});tapBoth returns the original Result; a thrown observer error is treated as a defect and becomes Panic.
Report defects at a supervision boundary handle-panic
import { Panic } from 'better-result';
try {
runApplication();
} catch (error) {
if (Panic.is(error)) reportDefect(error.message, error.cause);
else throw error;
}Panic represents an unexpected throw or broken invariant, so routine Result handlers should not recover it as a domain error.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| neverthrow | npm | Choose it for an established Result and ResultAsync API with many existing integrations and examples. |
| oxide.ts | npm | Choose it when Rust-shaped Option and Result types should come from the same small package. |
| ts-results-es | npm | Choose it for an ESM Result and Option implementation without better-result's retry and codec layer. |
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

