npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

better-result

v3.0.0

Published

Lightweight Result type with generator-based composition

Readme

  • Make failures explicit: TypeScript shows what a function returns when it succeeds and what can go wrong.
  • Write multi-step workflows in order: yield* passes successful values to the next step and stops on the first failure.
  • Keep expected failures separate from bugs: handle expected failures as normal return values; unexpected exceptions still throw.
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 && port <= 65_535
    ? Result.ok(port)
    : Result.err(new InvalidPort({ input, message: "Expected a port from 1 to 65535" }));
};

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

parsePort returns Result<number, InvalidPort>. Callers cannot use the port until they handle the failure.

Install

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

better-result requires TypeScript 5.4 or newer, is ESM-only, and has zero runtime dependencies.

Contents

Mental model

A Result<T, E> is either a successful Ok<T> or an expected failure Err<E>:

type Result<T, E> = Ok<T, E> | Err<T, E>;

Both variants have a serializable discriminant:

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

Static and instance guards are also available:

if (Result.isOk(userResult)) {
  renderUser(userResult.value);
}

if (userResult.isErr()) {
  reportUserError(userResult.error);
}

Use Err when the caller can make a meaningful decision about a failure:

  • input is invalid;
  • a record is missing;
  • credentials are rejected;
  • an upstream service is unavailable;
  • data crossing a transport boundary fails validation.

Unexpected callback failures and broken invariants are defects. better-result represents those with Panic instead of silently widening a typed error union with unknown.

A useful Result boundary has a caller that can act on the error. Parsers, repositories, adapters, domain operations, and application workflows are good candidates. Pure, total helpers usually are not.

Build a typed workflow

This checkout workflow shows the normal path: define errors, return Results, compose operations, and handle the complete error union.

Define errors callers can distinguish

TaggedError creates real Error subclasses with a literal _tag and typed properties:

import { Result, TaggedError, type Result as ResultType } from "better-result";

class CartNotFound extends TaggedError("CartNotFound")<{
  cartId: string;
  message: string;
}> {}

class EmptyCart extends TaggedError("EmptyCart")<{
  cartId: string;
  message: string;
}> {}

class OutOfStock extends TaggedError("OutOfStock")<{
  sku: string;
  message: string;
}> {}

class PaymentDeclined extends TaggedError("PaymentDeclined")<{
  reason: string;
  message: string;
}> {}

Tagged errors include normal Error behavior, readonly payload properties, .toJSON(), a class-level .is() guard, exhaustive .match(), and generator support.

const error = new CartNotFound({
  cartId: "cart_123",
  message: "Cart cart_123 was not found",
});

if (CartNotFound.is(error)) {
  console.log(error.cartId);
}

Return Results from fallible operations

type Cart = {
  id: string;
  items: ReadonlyArray<{ sku: string; quantity: number }>;
};

const carts = new Map<string, Cart>();

const findCart = (cartId: string): ResultType<Cart, CartNotFound> => {
  const cart = carts.get(cartId);
  return cart === undefined
    ? Result.err(new CartNotFound({ cartId, message: "Cart not found" }))
    : Result.ok(cart);
};

The error type is part of the function's contract. A caller must propagate, recover from, or handle CartNotFound.

Compose linearly with Result.gen

Assume the application also provides these Result-returning operations:

reserveStock(cart.items); // Result<StockReservation, OutOfStock>
chargePayment(cart, reservation); // Result<Receipt, PaymentDeclined>

Result.gen composes them without nested callbacks or manual early returns:

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>

Every Ok is unwrapped. The first Err short-circuits the generator. Errors from all yielded Results are collected into the final union.

A tagged error can be yielded directly for a guard clause. This is equivalent to yield* Result.err(new EmptyCart(...)); it returns an Err and does not throw.

Handle the complete error union

Use Result.match to handle success versus failure, then match the tagged error union:

const response = checkout(cartId).match({
  ok: (receipt) => Response.json(receipt, { status: 201 }),
  err: (error) =>
    error.match({
      CartNotFound: () => Response.json({ message: "Cart not found" }, { status: 404 }),
      EmptyCart: () => Response.json({ message: "Cart is empty" }, { status: 400 }),
      OutOfStock: (error) =>
        Response.json({ message: `Out of stock: ${error.sku}` }, { status: 409 }),
      PaymentDeclined: () => Response.json({ message: "Payment declined" }, { status: 402 }),
    }),
});

Adding another tagged error to checkout makes this exhaustive handler fail to type-check until the new policy is defined.

Use matchError when errors are structurally tagged or when data-last composition is more convenient. Use matchErrorPartial when selected variants should be transformed and unhandled variants should pass through.

Compose asynchronous workflows

Prefer Result.gen with Result.await for multi-step asynchronous workflows. It keeps intermediate values local, short-circuits on the first Err, and preserves every yielded error type:

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 });
});
// Result<Dashboard, SessionExpired | UserNotFound | FetchPostsFailed>

Result.await provides the async iterator protocol needed by the generator while preserving the Promise's Result types.

For a short pipeline, chain the Promise with static, data-last combinators from the Result namespace:

const postCount = await fetchUser(userId)
  .then(Result.andThenAsync((user: User) => fetchPosts(user.id)))
  .then(Result.map((posts: ReadonlyArray<Post>) => posts.length));
// Result<number, UserNotFound | FetchPostsFailed>

Promise.then unwraps each outer Promise. Result.andThenAsync runs fetchPosts only for Ok, and Result.map transforms the eventual success while both errors remain visible.

Use this order of preference for asynchronous Result code:

  1. Result.gen with Result.await for workflows with several steps or intermediate values;
  2. .then(Result.andThenAsync(...)) and other static combinators for short Promise pipelines;
  3. await a Promise<Result> first only when ordinary control-flow narrowing is clearer than composition.

Result.gen closes a short-circuited generator, so finally, Symbol.dispose, and Symbol.asyncDispose cleanup can run. See Generator composition for cleanup and defect behavior.

Transform and compose Results

Use each operation on the branch it owns:

| Operation | Runs on | Purpose | | ------------------ | --------------- | ------------------------------------------------ | | map | Ok | Transform a success value | | mapError | Err | Translate an error value | | andThen | Ok | Continue with another Result-returning operation | | tryRecover | Err | Recover from or replace an error | | tap / tapError | Selected branch | Observe without changing the Result | | match | Both | Leave the Result abstraction with one output |

For example, a profile workflow can keep its errors visible while changing the success value:

const displayName = findUser(userId)
  .map((user) => user.profile)
  .andThen(validateUserProfile)
  .map((profile) => profile.displayName)
  .mapError((cause) => new LoadProfileFailed({ cause, message: "Could not load user profile" }));
// Result<string, LoadProfileFailed>

andThen unions errors when the next operation introduces another failure type:

const greeting = findUser(userId).andThen((user) => loadGreeting(user.locale));
// Result<Greeting, UserNotFound | GreetingLoadFailed>

Combinators are available as instance methods and as static data-first or data-last functions:

const upperName = Result.map(userResult, (user) => user.name.toUpperCase());

const getUpperName = Result.map((user: User) => user.name.toUpperCase());
const pipedName = getUpperName(userResult);

See Transforming and chaining for the complete sync and async contracts.

Recover from errors

Recovery is different from error transformation: the callback returns another Result and may produce a usable success value.

A cache fallback can recover from a network failure while preserving all other variants:

const user = await fetchUser(userId).then(
  Result.tryRecoverAsync(async (error: FetchUserError) =>
    error._tag === "NetworkUnavailable" ? await readCachedUser(userId) : Result.err(error),
  ),
);
// Result<User, UserNotFound | CacheMiss>

Recovery may widen the success type when the fallback returns a different value:

const userOrGuest = findUser(userId).tryRecover((error) =>
  UserNotFound.is(error) ? Result.ok(guestUser) : Result.err(error),
);
// Result<User | GuestUser, DatabaseUnavailable>

Use tryRecoverAsync when recovery itself is asynchronous.

Observe without changing a Result

Observation methods are useful for logging, metrics, and tracing. They always preserve the original Result.

const tracedUser = 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 }),
  }),
);

The complete family is:

  • tap and tapAsync observe Ok;
  • tapError and tapErrorAsync observe Err;
  • tapBoth and tapBothAsync select an observer for either branch.
const result = parseConfiguration(input)
  .tap((configuration) => console.info("Configuration loaded", configuration))
  .tapError((error) => console.error("Configuration rejected", error));

A throwing or rejected observer is a defect and becomes Panic.

Extract a value

Prefer match when both branches require explicit policy:

const response = userResult.match({
  ok: (user) => Response.json(user, { status: 200 }),
  err: (error) => toUserErrorResponse(error),
});

Use unwrapOr when a fallback is the complete error policy:

const port = parsePort(process.env.PORT ?? "").unwrapOr(3000);

Static data-first and data-last forms are also available:

Result.unwrapOr(parsePort(input), 3000);
Result.unwrapOr(3000)(parsePort(input));

Use unwrap only to assert that Err would prove a broken invariant:

const configuration = loadStartupConfiguration().unwrap("Startup configuration must be valid");

On Err, unwrap throws Panic and preserves the error value as its cause. It is not a substitute for handling routine failures.

See Extracting values for the full contract.

Retry asynchronous operations

Result.tryPromise captures Promise rejection. The object form translates unknown rejection values into a typed error:

class NetworkError extends TaggedError("NetworkError")<{
  cause: unknown;
  url: string;
  retryable: boolean;
  message: string;
}> {}

const controller = new AbortController();

const responseResultPromise = Result.tryPromise(
  {
    try: ({ signal }) => fetch(url, { signal }),
    catch: (cause) =>
      new NetworkError({
        cause,
        url,
        retryable: cause instanceof TypeError,
        message: "Network request failed",
      }),
  },
  {
    signal: controller.signal,
    retry: {
      times: 3,
      delayMs: 100,
      backoff: "exponential",
      jitter: true,
      shouldRetry: (error) => error.retryable,
    },
  },
);

times is the maximum number of retries after the initial attempt. Retry scheduling is bounded, and the top-level signal interrupts pending delays.

Result.tryPromise cannot cancel an operation by itself. Forward its signal to cancellation-aware operations, as the example does with fetch.

A fulfilled HTTP error response does not reject. Handle HTTP status explicitly:

class HttpResponseError extends TaggedError("HttpResponseError")<{
  status: number;
  url: string;
  message: string;
}> {}

const successfulResponse = await responseResultPromise.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<Response, NetworkError | HttpResponseError>

Retry policies also support attempt context, constant and linear backoff, dynamic error-dependent delays, and configurable jitter. See Async operations and retries for exact semantics.

Work with collections

Use Result.all when every operation must succeed:

const accountContext = Result.all([
  loadCachedUser(userId),
  loadCachedTeam(teamId),
  loadCachedPlan(accountId),
] as const);
// Result<[User, Team, Plan], UserLoadError | TeamLoadError | PlanLoadError>

Result.allAsync awaits inputs concurrently, then returns all successes or the first input-order error:

const accountContext = await Result.allAsync([
  fetchUser(userId),
  fetchTeam(teamId),
  fetchPlan(accountId),
] as const);

Use Result.partition when every item should be processed:

const validationResults = importRows.map(validateImportRow);
const [validRows, invalidRowErrors] = Result.partition(validationResults);

partition preserves the relative order of every success and error instead of short-circuiting. partitionAsync provides the concurrent asynchronous form.

Use Result.flatten when a nested Result already exists:

const flattened = Result.flatten(nestedUserResult);
// Result<User, ParseUserError | LoadUserError>

See Collections for tuple inference, ordering, and rejected-Promise behavior.

Validate transport boundaries

Result.codec validates and transforms Result values crossing RPC, persistence, queue, or server-action boundaries. It accepts any Standard Schema-compatible schema library.

Pass four named, boundary-owned schemas to the codec. Keep validation and domain/wire mapping inside those schemas rather than defining it inline in Result.codec:

import { Result, ResultDeserializationError, ResultSerializationError } from "better-result";

const UserResultCodec = Result.codec({
  serialize: {
    ok: UserToWireSchema,
    err: DomainErrorToWireSchema,
  },
  deserialize: {
    ok: UserFromWireSchema,
    err: DomainErrorFromWireSchema,
  },
});

Serialize a Result into a validated plain-object envelope:

const encoded = await UserResultCodec.serialize(Result.ok(user));
// Result<SerializedResult<UserWire, ErrorWire>, ResultSerializationError>

if (Result.isError(encoded) && ResultSerializationError.is(encoded.error)) {
  console.error("Could not serialize user Result", encoded.error.issues);
}

Deserialize untrusted input back into validated domain values:

const decoded = await UserResultCodec.deserialize(inputFromNetwork);
// Result<User, DomainError | ResultDeserializationError>

if (Result.isError(decoded) && ResultDeserializationError.is(decoded.error)) {
  console.error("Invalid serialized Result", decoded.error.issues);
}

When you own both producer and consumer and version their schemas together, the unsafe variants are often the simpler choice. In that setting, a codec validation error usually means the shared contract is broken rather than an expected failure. The unsafe methods remove the codec-error handling layer and its associated unwrapping or translation boilerplate:

const envelope = await UserResultCodec.serializeUnsafe(Result.ok(user));
// SerializedResult<UserWire, ErrorWire>

const decoded = await UserResultCodec.deserializeUnsafe(inputFromNetwork);
// Result<User, DomainError>

serializeUnsafe panics when serialization returns ResultSerializationError. deserializeUnsafe panics only when validation returns ResultDeserializationError; a valid serialized Err remains a decoded domain Err. Both methods preserve the selected schema's synchronous or asynchronous behavior. Prefer the safe methods for public, independently versioned, persisted, or otherwise untrusted boundaries where contract mismatch is an expected condition.

The codec validates the outer { status, value | error } envelope and the selected payload. In-memory and wire types can differ in both directions. A schema issue returns ResultSerializationError or ResultDeserializationError; a schema that throws or rejects is a defect and produces Panic.

See Result codecs for mixed synchronous/asynchronous schemas and exact return-type inference.

Panic and defects

Err represents an expected failure in the function's contract. Panic represents a defect that ordinary callers should not recover from.

If a user callback unexpectedly throws, better-result throws Panic rather than adding unknown to the Result's error type:

Result.ok(user).map(() => {
  throw new Error("Broken user invariant");
});
// throws Panic

This protection applies to transforms, chaining, matching, recovery, observers, generators, custom catch handlers, and codec validation.

Catch Panic at reporting or supervision boundaries:

import { Panic } from "better-result";

try {
  runApplication();
} catch (error) {
  if (Panic.is(error)) {
    reportDefect(error.message, error.cause);
  }
}

isPanic(error) and error instanceof Panic are also supported. Panic.cause preserves the original thrown value.

See Panic and defects for callback boundaries, generator cleanup, and reporting behavior.

API map

The complete API reference is the source of truth for signatures and overloads.

| Intent | APIs | | ---------------------- | ----------------------------------------------------------------------------------------------------------- | | Create | Result.ok, Result.err, Result.try, Result.tryPromise | | Narrow and handle | Result.isOk, Result.isError, match, unwrapOr, unwrap | | Transform and compose | map, mapError, andThen, andThenAsync, tryRecover, tryRecoverAsync, Result.gen, Result.await | | Observe | tap, tapAsync, tapError, tapErrorAsync, tapBoth, tapBothAsync | | Collect | Result.all, Result.allAsync, Result.partition, Result.partitionAsync, Result.flatten | | Typed errors | TaggedError, matchError, matchErrorPartial, isTaggedError | | Boundaries and defects | Result.codec, serializeUnsafe, deserializeUnsafe, Panic, panic, isPanic, UnhandledException |

Public types

| Type | Purpose | | ----------------------------------- | --------------------------------------------- | | Result<T, E> | Union of Ok<T, E> and Err<T, E> | | InferOk<R> | Extract the success type from a Result | | InferErr<R> | Extract the error type from a Result | | TryContext | Synchronous attempt context | | TryPromiseContext | Asynchronous attempt and abort-signal context | | ResultCodec / ResultCodecConfig | Result codec contracts | | SerializedResult<T, E> | Plain-object Result envelope | | StandardSchemaV1 | Standard Schema-compatible validator contract | | AnyTaggedError | Any better-result tagged error instance |

See Ok and Err, exported types, and error APIs for detailed contracts.

Migrate from 2.x

Read the full 2.x to 3.0 migration guide before upgrading.

The most important breaking changes are:

  1. TaggedError("Tag")<Props> no longer has a trailing factory call.
  2. Result.serialize, Result.deserialize, and Result.hydrate were replaced by schema-backed Result.codec boundaries.
  3. match is reserved on TaggedError instances.
// 2.x
class UserNotFound extends TaggedError("UserNotFound")<{ userId: string; message: string }>() {}

// 3.0
class UserNotFound extends TaggedError("UserNotFound")<{ userId: string; message: string }> {}

3.0 also adds widening recovery, direct tagged-error matching, collection helpers, cancellation-aware retries, dynamic retry delays, and richer observation APIs.

Agents and AI

The portable adopt-better-result skill guides compatible coding agents through a repository-wide error-handling audit or one approved vertical migration slice.

Install it with skills.sh-compatible tooling:

npx skills add dmmulroy/better-result@adopt-better-result

See skills/README.md for manual installation and usage.

Documentation

License

MIT