Library / SDK
dmmulroy/better-result avatar
dmmulroy/better-result

better-result: typed error handling with generator composition in TypeScript

Lightweight Result type for TypeScript with generator-based composition.

1,995 stars44 forksTypeScriptMIT

At a glance

What is it?
better-result is a TypeScript library that represents expected failures as typed return values and composes multi-step fallible workflows with generator syntax. It is ESM-only, requires TypeScript 5.4 or newer, and ships with zero runtime dependencies.
Who is it for?
Use better-result when you want expected failures to appear in type signatures and need to compose them across a multi-step workflow without nested callbacks. It fits TypeScript projects committed to strict types and ESM.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 49 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 11, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Failures that belong in a return type and failures that should still throw

better-result separates two kinds of failures. Expected failures are situations where the caller has a meaningful decision to make: invalid input, a missing record, rejected credentials, an unavailable upstream service, and data that fails validation when crossing a transport boundary. These travel as the failure side of a `Result<T, E>`. Unexpected callback failures and broken invariants are defects, and better-result represents those with `Panic` instead of silently widening a typed error union with `unknown`.

The distinction shapes where you add Results in a codebase. Parsers, repositories, adapters, domain operations, and application workflows are good candidates, because their callers have concrete responses to failures. Pure, total helpers usually are not, since a helper that can only fail due to a programmer error offers no response its caller could meaningfully act on at runtime.

The practical consequence is that error types become part of the function signature. A function returning `Result<number, InvalidPort>` forces the caller to handle `InvalidPort` before using the number. Whether that trade-off is worth the additional type machinery depends on how many callers need to distinguish that specific error from others.

TaggedError ties a literal discriminant to typed payload fields on a real Error subclass

`TaggedError` is a class factory. It takes a string literal and returns a base class, and the caller extends that base class with a type parameter for the payload:

ts
class InvalidPort extends TaggedError("InvalidPort")<{
  input: string;
  message: string;
}> {}

The resulting class is a real `Error` subclass. It carries the string literal as a `_tag` field, and the payload fields are readonly. Beyond that, each TaggedError class has a class-level `.is()` guard for narrowing, an instance `.toJSON()` method for serialization, and support for exhaustive `.match()` on typed error unions. Generator support is built in as well, which enables a guard clause: a TaggedError instance can be yielded directly inside `Result.gen` without wrapping it in `Result.err`, which is equivalent to `yield* Result.err(new EmptyCart(...))`.

Extending real `Error` subclasses rather than plain objects means error instances carry a stack trace, and existing error-logging infrastructure does not need adjustments to recognise them.

npm install better-result, with an ESM-only package and a TypeScript 5.4 floor

The package is on npm. Three package managers work:

sh
npm install better-result
sh
pnpm add better-result
# or: bun add better-result

Two constraints should be checked before installation. First, the package is ESM-only. The package.json sets `"type": "module"` and the exports map contains only `.mjs` and `.d.mts` files with no CommonJS entry point. A project that cannot import ES modules will need a bundler that can rewrite the import, or needs to migrate to ESM first.

Second, TypeScript 5.4 is the minimum. The package.json includes a `check:typescript-minimum` script that runs `tsc -p tsconfig.typescript-minimum.json` against a pinned `[email protected]`. That specific patch version is what the project actually validates against, not just any 5.4.x build. Someone on an earlier patch within the 5.4 line should run that script in their own environment before depending on the library.

The package carries zero runtime dependencies. Build output in `dist/` is produced by tsdown, and the `files` array in package.json limits publication to `dist/index.d.mts`, `dist/index.d.mts.map`, `dist/index.mjs` and `dist/index.mjs.map`.

Result.gen composes a multi-step workflow with automatic error union inference

`Result.gen` takes a generator function and runs it. Inside the function, every Ok is unwrapped and the generator continues; the first Err short-circuits the generator and becomes the return value. Every error type from each `yield*` call is merged into the final return union automatically.

ts
const checkout = (cartId: string) =>
  Result.gen(function* () {
    const cart = yield* findCart(cartId);

    if (cart.items.length === 0) {
      yield* new EmptyCart({ cartId, message: "Cannot check out an empty cart" });
    }

    const reservation = yield* reserveStock(cart.items);
    const receipt = yield* chargePayment(cart, reservation);

    return Result.ok(receipt);
  });
// Result<Receipt, CartNotFound | EmptyCart | OutOfStock | PaymentDeclined>

If `findCart` returns an Err, the generator stops there and `checkout` returns that error. The inferred return type, `Result<Receipt, CartNotFound | EmptyCart | OutOfStock | PaymentDeclined>`, collects the error types from all three operations without any manual annotation.

Yielding a TaggedError instance directly in a guard clause is equivalent to `yield* Result.err(new EmptyCart(...))` and produces an Err return from the generator. A table of contents entry in the repository covers composing asynchronous workflows, so the generator approach also applies to async operations. The documentation at better-result.dev covers that in full.

The status discriminant is a plain string, and three guard styles narrow the type

Both `Ok<T>` and `Err<T, E>` carry a `status` field, and both variants have what the library calls a serializable discriminant. Checking `status === "ok"` narrows the type to the Ok variant; the else branch narrows to the Err variant.

ts
if (userResult.status === "ok") {
  renderUser(userResult.value); // User
} else {
  reportUserError(userResult.error); // FindUserError
}

Three guard styles work on a Result: checking `status` directly is the plain form. The static `Result.isOk` method and the instance `.isErr()` method are also available, and both narrow the type. For handling both branches in one call, `match` takes an object with ok and err keys:

ts
const message = parsePort(process.env.PORT ?? "3000")
  .map((port) => `http://localhost:${port}`)
  .match({
    ok: (address) => `Listening at ${address}`,
    err: (error) => `Invalid configuration: ${error.message}`,
  });

TaggedError instances support an inner `.match()` call that dispatches on the `_tag` field, which is how a composed workflow's full error union gets exhaustively handled, with one key per TaggedError class name.

Where better-result adds type overhead without benefit and where its constraints block adoption

Not every function returns a useful Result. Pure, total helpers that can only fail because of programmer errors should throw instead. Returning a Result for such a case forces the caller to pattern-match on an outcome that the caller cannot act on, and the added type machinery gives no useful information.

The ESM-only package is a real barrier for projects on CommonJS. Many Node.js applications targeting older toolchains have not migrated, and adding a bundler step purely to consume one utility library is a cost that may outweigh the benefit. The package.json documents no CommonJS build and no plan to add one.

The README introduces `Panic` as the representation for unexpected defects but does not describe a recovery path in that section. How a `Panic` behaves at a top-level error boundary, whether it is distinguishable from ordinary exceptions, and whether there is a way to catch Panic separately are questions for the documentation at better-result.dev before adopting the library in a context where all exceptions are caught at a single boundary.

The v3 migration boundary and an AGENTS.md that documents AI agent usage

Users of 2.x need to read the migration guide at better-result.dev/migration/from-2 before upgrading, since the 3.x release introduced breaking changes. The last push to the repository was on 2026-08-23, and the package is distributed under the MIT license.

The repository root includes an AGENTS.md file and a `.agents/` directory alongside the source. One section in the repository's README is titled "Agents and AI". That entry is unusual for a TypeScript error-handling library. The documentation at better-result.dev has the full text of that section.

The devDependencies in package.json include vitest, oxlint, and oxfmt for the test and lint tooling, none of which ship with the package at runtime.

Editorial conclusion

Use better-result when you want expected failures to appear in type signatures and need to compose them across a multi-step workflow without nested callbacks. It fits TypeScript projects committed to strict types and ESM. Skip it if your codebase is CommonJS, since the package ships only .mjs and no CommonJS fallback is provided. If your project uses 2.x, read the migration guide at better-result.dev/migration/from-2 before upgrading to v3, since v3.0.0 introduced breaking changes. Verify your TypeScript version is at least 5.4; the check:typescript-minimum script in package.json tests against [email protected] specifically.

Frequently asked questions

What is better-result?

better-result is a TypeScript library that provides a Result type for typed error handling. Expected failures are represented as Err values, multi-step workflows are composed with generator syntax via Result.gen, and unexpected defects are represented with Panic so they still throw as exceptions.

How do I install better-result?

Run npm install better-result, pnpm add better-result, or bun add better-result. The package requires TypeScript 5.4 or newer and is ESM-only with no CommonJS build.

What is the difference between Err and Panic in better-result?

Err represents an expected failure that the caller can meaningfully handle, such as invalid input or a missing record. Panic represents a defect or broken invariant, and better-result represents those with Panic instead of silently widening a typed error union with unknown.

Does better-result work with CommonJS?

No. The package is ESM-only. The package.json exports only .mjs and .d.mts files, and there is no CommonJS entry point.

What changed in better-result v3?

The 3.x release introduced breaking changes from 2.x. A migration guide for 2.x users is at better-result.dev/migration/from-2.

Official sources

  1. dmmulroy/better-result on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/dmmulroy-better-result.svg)](https://hysenlabs.com/projects/dmmulroy-better-result)