mrkeyoor.com_
Sat 08 Aug 21:01 UTC
npmUtilsupdated 08 Aug 2026

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.

Verdict

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.

API stability3/5The core Ok, Err, map, match, and composition ideas are conventional, but 3.0 shipped in August 2026 with documented breaking changes: TaggedError subclass syntax changed, match became a reserved instance method, and three serialization helpers were replaced by Result.codec. The project supplies a 2.x migration guide, which helps, but consumers should expect a deliberate upgrade rather than a drop-in version bump.
Docs5/5The README explains the error model before listing methods, shows complete synchronous and asynchronous workflows, distinguishes rejection from HTTP failure, documents retry counting and cancellation, and links to a dedicated reference plus migration and testing guides. Examples state inferred Result types and call out where Panic can appear, which makes the subtle behavior unusually inspectable.
Maintenance5/5Version 3.0.0 was published on August 1, 2026 and the repository was pushed the same day. The repository is not archived and currently reports only four open issues and pull requests combined. A same-day major release, current documentation, explicit migration material, and a small active queue are strong evidence of hands-on maintenance, though the project remains young enough that continuity is not yet proven over many years.
Ecosystem4/5The package records 5,145,323 downloads in the latest npm week and the GitHub repository has 1,856 stars, substantial reach for a typed error utility. It accepts Standard Schema-compatible validators instead of tying codecs to one validation package and ships its own TypeScript declarations. The surrounding ecosystem is still smaller than neverthrow's, and ESM-only packaging excludes some established Node.js codebases.

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
Skip it if

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

PackageRegistryPick it when
neverthrownpmChoose it for a widely adopted Result and ResultAsync API with a narrower conceptual model
oxide.tsnpmChoose it when Rust-like Option and Result types are both central to your code style
ts-results-esnpmChoose it for a small ESM Result and Option implementation with familiar Rust-inspired names