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

v2.9.2

Published

Lightweight Result type with generator-based composition

Readme

better-result

Lightweight Result type for TypeScript with generator-based composition.

📖 Documentation

Install

npm install better-result

Or with Bun / pnpm:

bun add better-result
pnpm add better-result

Quick Start

import { Result } from "better-result";

// Wrap throwing functions
const parsed = Result.try(() => JSON.parse(input));

// Check and use
if (Result.isOk(parsed)) {
  console.log(parsed.value);
} else {
  console.error(parsed.error);
}

// Or use pattern matching
const message = parsed.match({
  ok: (data) => `Got: ${data.name}`,
  err: (e) => `Failed: ${e.message}`,
});

Contents

Creating Results

// Success
const ok = Result.ok(42);

// Error
const err = Result.err(new Error("failed"));

// From throwing function
const result = Result.try(() => riskyOperation());

// From promise
const result = await Result.tryPromise(() => fetch(url));

// With custom error handling
const result = Result.try({
  try: () => JSON.parse(input),
  catch: (e) => new ParseError(e),
});

Transforming Results

const result = Result.ok(2)
  .map((x) => x * 2) // Ok(4)
  .andThen(
    (
      x, // Chain Result-returning functions
    ) => (x > 0 ? Result.ok(x) : Result.err("negative")),
  );

// Standalone functions (data-first or data-last)
Result.map(result, (x) => x + 1);
Result.map((x) => x + 1)(result); // Pipeable

Handling Errors

// Transform error type
const result = fetchUser(id).mapError((e) => new AppError(`Failed to fetch user: ${e.message}`));

// Recover from specific errors while preserving the same success type
const result = fetchUser(id).tryRecover((e) =>
  e._tag === "NotFoundError" ? Result.ok(defaultUser) : Result.err(e),
);

// Async recovery follows the same pattern
// If fetchUser is async and returns Promise<Result<User, E>>, await it first.
const result = await (
  await fetchUser(id)
).tryRecoverAsync(async (e) =>
  e._tag === "NetworkError" ? Result.ok(await readUserFromCache(id)) : Result.err(e),
);

Observing Results

Use tap / tapAsync for success-side logging or tracing, tapError / tapErrorAsync for error-side logging or tracing, and tapBoth / tapBothAsync when you want to observe either branch with one handler object. These methods do not transform the Result — they always return the original value unchanged.

const result = Result.try(() => JSON.parse(input))
  .tap((value) => {
    console.debug("parsed payload", value);
  })
  .tapError((error) => {
    console.error("failed to parse payload", error);
  });

If you want to observe both branches symmetrically with one call, use tapBoth:

const result = Result.try(() => JSON.parse(input)).tapBoth({
  ok: (value) => {
    console.info("decoded payload", value);
  },
  err: (error) => {
    console.warn("decode failed", error);
  },
});

Async side effects follow the same pattern:

const result = await Result.err("request failed").tapErrorAsync(async (error) => {
  await trace("request.failed", { error });
});

tapBothAsync works the same way for async observers on either branch:

const observed = await Result.tapBothAsync(
  Result.try(() => JSON.parse(input)),
  {
    ok: async (value) => {
      await trace("payload.decoded", { value });
    },
    err: async (error) => {
      await trace("payload.decode_failed", { error });
    },
  },
);

Static helpers support both data-first and data-last styles:

const traced = Result.tapError(Result.err("cache miss"), (error) => {
  console.warn("cache lookup failed", error);
});

const traceError = Result.tapErrorAsync(async (error: string) => {
  await trace("cache.lookup_failed", { error });
});

await traceError(Result.err("cache miss"));

If you prefer, you can still observe both branches by chaining tap and tapError separately.

Thrown or rejected side-effect callbacks become Panic, just like other Result callbacks.

Extracting Values

// Unwrap (throws on Err)
const value = result.unwrap();
const value = result.unwrap("custom error message");

// With fallback
const value = result.unwrapOr(defaultValue);

// Pattern match
const value = result.match({
  ok: (v) => v,
  err: (e) => fallback,
});

Generator Composition

Chain multiple Results without nested callbacks or early returns:

const result = Result.gen(function* () {
  const a = yield* parseNumber(inputA); // Unwraps or short-circuits
  const b = yield* parseNumber(inputB);
  const c = yield* divide(a, b);
  return Result.ok(c);
});
// Result<number, ParseError | DivisionError>

Async version with Result.await:

const result = await Result.gen(async function* () {
  const user = yield* Result.await(fetchUser(id));
  const posts = yield* Result.await(fetchPosts(user.id));
  return Result.ok({ user, posts });
});

Errors from all yielded Results are automatically collected into the final error union type.

Normalizing Error Types

Use mapError on the output of Result.gen() to unify multiple error types into a single type:

class ParseError extends TaggedError("ParseError")<{ message: string }>() {}
class ValidationError extends TaggedError("ValidationError")<{ message: string }>() {}
class AppError extends TaggedError("AppError")<{ source: string; message: string }>() {}

const result = Result.gen(function* () {
  const parsed = yield* parseInput(input); // Err: ParseError
  const valid = yield* validate(parsed); // Err: ValidationError
  return Result.ok(valid);
}).mapError((e): AppError => new AppError({ source: e._tag, message: e.message }));
// Result<ValidatedData, AppError> - error union normalized to single type

Retry Support

const result = await Result.tryPromise(() => fetch(url), {
  retry: {
    times: 3,
    delayMs: 100,
    backoff: "exponential", // or "linear" | "constant"
  },
});

Conditional Retry

Retry only for specific error types using shouldRetry:

class NetworkError extends TaggedError("NetworkError")<{ message: string }>() {}
class ValidationError extends TaggedError("ValidationError")<{ message: string }>() {}

const result = await Result.tryPromise(
  {
    try: () => fetchData(url),
    catch: (e) =>
      e instanceof TypeError // Network failures often throw TypeError
        ? new NetworkError({ message: (e as Error).message })
        : new ValidationError({ message: String(e) }),
  },
  {
    retry: {
      times: 3,
      delayMs: 100,
      backoff: "exponential",
      shouldRetry: (e) => e._tag === "NetworkError", // Only retry network errors
    },
  },
);

Async Retry Decisions

For retry decisions that require async operations (rate limits, feature flags, etc.), enrich the error in the catch handler instead of making shouldRetry async:

class ApiError extends TaggedError("ApiError")<{
  message: string;
  rateLimited: boolean;
}>() {}

const result = await Result.tryPromise(
  {
    try: () => callApi(url),
    catch: async (e) => {
      // Fetch async state in catch handler
      const retryAfter = await redis.get(`ratelimit:${userId}`);
      return new ApiError({
        message: (e as Error).message,
        rateLimited: retryAfter !== null,
      });
    },
  },
  {
    retry: {
      times: 3,
      delayMs: 100,
      backoff: "exponential",
      shouldRetry: (e) => !e.rateLimited, // Sync predicate uses enriched error
    },
  },
);

UnhandledException

When Result.try() or Result.tryPromise() catches an exception without a custom handler, the error type is UnhandledException:

import { Result, UnhandledException } from "better-result";

// Automatic — error type is UnhandledException
const result = Result.try(() => JSON.parse(input));
//    ^? Result<unknown, UnhandledException>

// Custom handler — you control the error type
const result = Result.try({
  try: () => JSON.parse(input),
  catch: (e) => new ParseError(e),
});
//    ^? Result<unknown, ParseError>

// Same for async
await Result.tryPromise(() => fetch(url));
//    ^? Promise<Result<Response, UnhandledException>>

Access the original exception via .cause:

if (Result.isError(result)) {
  const original = result.error.cause;
  if (original instanceof SyntaxError) {
    // Handle JSON parse error
  }
}

Panic

Thrown (not returned) when user callbacks throw inside Result operations. Represents a defect in your code, not a domain error.

import { Panic, isPanic } from "better-result";

// Callback throws → Panic
Result.ok(1).map(() => {
  throw new Error("bug");
}); // throws Panic

// Generator cleanup throws → Panic
Result.gen(function* () {
  try {
    yield* Result.err("expected failure");
  } finally {
    throw new Error("cleanup bug");
  }
}); // throws Panic

// Catch handler throws → Panic
Result.try({
  try: () => riskyOp(),
  catch: () => {
    throw new Error("bug in handler");
  },
}); // throws Panic

// Catching Panic (for error reporting)
try {
  result.map(() => {
    throw new Error("bug");
  });
} catch (error) {
  if (isPanic(error)) {
    // isPanic() is a type guard function
    console.error("Defect:", error.message, error.cause);
  }

  if (Panic.is(error)) {
    // Panic.is() is a static method (same behavior)
  }

  if (error instanceof Panic) {
    // instanceof works too
  }
}

Why Panic? Err is for recoverable domain errors. Panic is for bugs — like Rust's panic!(). If your .map() callback throws, that's not an error to handle, it's a defect to fix. Returning Err would collapse type safety (Result<T, E> becomes Result<T, E | unknown>).

Panic properties:

| Property | Type | Description | | --------- | --------- | ----------------------------- | | message | string | Describes where/what panicked | | cause | unknown | The exception that was thrown |

Panic also provides toJSON() for error reporting services (Sentry, etc.).

Tagged Errors

Build exhaustive error handling with discriminated unions:

import { Result, TaggedError, matchError, matchErrorPartial } from "better-result";

// Factory API: TaggedError("Tag")<Props>()
class NotFoundError extends TaggedError("NotFoundError")<{
  id: string;
  message: string;
}>() {}

class ValidationError extends TaggedError("ValidationError")<{
  field: string;
  message: string;
}>() {}

type AppError = NotFoundError | ValidationError;

// Create errors with object args
const err = new NotFoundError({ id: "123", message: "User not found" });

// Exhaustive matching
matchError(error, {
  NotFoundError: (e) => `Missing: ${e.id}`,
  ValidationError: (e) => `Bad field: ${e.field}`,
});

// Partial matching with fallback
matchErrorPartial(
  error,
  { NotFoundError: (e) => `Missing: ${e.id}` },
  (e) => `Unknown: ${e.message}`,
);

// Type guards
TaggedError.is(value); // any tagged error
NotFoundError.is(value); // specific class

Yielding Tagged Errors in Result.gen

Tagged errors can short-circuit Result.gen directly. This is useful for recoverable domain errors and is equivalent to yielding Result.err(error); it does not throw.

const result = Result.gen(function* () {
  yield* new NotFoundError({ id: "123", message: "missing" });
  return Result.ok("never reached");
});
// Result<string, NotFoundError>
// => Err(original NotFoundError instance)

They also compose with regular Result values and contribute to the inferred error union:

const result = Result.gen(function* () {
  const user = yield* findUser("123"); // Result<User, NotFoundError>

  if (!user.active) {
    yield* new ValidationError({ field: "active", message: "User is inactive" });
  }

  return Result.ok(user);
});
// Result<User, NotFoundError | ValidationError>

For errors with computed messages, add a custom constructor:

class NetworkError extends TaggedError("NetworkError")<{
  url: string;
  status: number;
  message: string;
}>() {
  constructor(args: { url: string; status: number }) {
    super({ ...args, message: `Request to ${args.url} failed: ${args.status}` });
  }
}

new NetworkError({ url: "/api", status: 404 });

Serialization

Convert Results to plain objects for RPC, storage, or server actions:

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

// Serialize to plain object
const result = Result.ok(42);
const serialized = Result.serialize(result);
// { status: "ok", value: 42 }

// Deserialize back to Result instance
const deserialized = Result.deserialize<number, never>(serialized);
// Ok(42) - can use .map(), .andThen(), etc.

// Invalid input returns ResultDeserializationError
const invalid = Result.deserialize({ foo: "bar" });
if (Result.isError(invalid) && ResultDeserializationError.is(invalid.error)) {
  console.log("Bad input:", invalid.error.value);
}

// Typed boundary for Next.js server actions
async function createUser(data: FormData): Promise<SerializedResult<User, ValidationError>> {
  const result = await validateAndCreate(data);
  return Result.serialize(result);
}

// Client-side
const serialized = await createUser(formData);
const result = Result.deserialize<User, ValidationError>(serialized);

API Reference

Result

| Method | Description | | --------------------------------------- | ---------------------------------------------------------------------------------------- | | Result.ok(value) | Create success | | Result.err(error) | Create error | | Result.try(fn) | Wrap throwing function | | Result.tryPromise(fn, config?) | Wrap async function with optional retry | | Result.isOk(result) | Type guard for Ok | | Result.isError(result) | Type guard for Err | | Result.gen(fn) | Generator composition | | Result.tryRecover(result, fn) | Recover error into same success type | | Result.tryRecoverAsync(result, fn) | Async recover error into same success type | | Result.tap(result, fn) | Run side effect on success and return original result | | Result.tapAsync(result, fn) | Run async side effect on success and return original result | | Result.tapError(result, fn) | Run side effect on error and return original result | | Result.tapErrorAsync(result, fn) | Run async side effect on error and return original result | | Result.tapBoth(result, handlers) | Run side effect on either branch and return original result | | Result.tapBothAsync(result, handlers) | Run async side effect on either branch and return original result | | Result.await(promise) | Wrap Promise for generators | | Result.serialize(result) | Convert Result to plain object | | Result.deserialize(value) | Rehydrate serialized Result (returns Err<ResultDeserializationError> on invalid input) | | Result.partition(results) | Split array into [okValues, errValues] | | Result.flatten(result) | Flatten nested Result |

Instance Methods

| Method | Description | | ------------------------- | ------------------------------------------ | | .isOk() | Type guard, narrows to Ok | | .isErr() | Type guard, narrows to Err | | .map(fn) | Transform success value | | .mapError(fn) | Transform error value | | .tryRecover(fn) | Recover error into same success type | | .tryRecoverAsync(fn) | Async recover error into same success type | | .andThen(fn) | Chain Result-returning function | | .andThenAsync(fn) | Chain async Result-returning function | | .match({ ok, err }) | Pattern match | | .unwrap(message?) | Extract value or throw | | .unwrapOr(fallback) | Extract value or return fallback | | .tap(fn) | Side effect on success | | .tapAsync(fn) | Async side effect on success | | .tapError(fn) | Side effect on error | | .tapErrorAsync(fn) | Async side effect on error | | .tapBoth(handlers) | Side effect on either branch | | .tapBothAsync(handlers) | Async side effect on either branch |

TaggedError

| Method | Description | | -------------------------------------- | ---------------------------------- | | TaggedError(tag)<Props>() | Factory for tagged error class | | TaggedError.is(value) | Type guard for any TaggedError | | matchError(err, handlers) | Exhaustive pattern match by _tag | | matchErrorPartial(err, handlers, fb) | Partial match with fallback | | isTaggedError(value) | Type guard (standalone function) | | panic(message, cause?) | Throw unrecoverable Panic | | isPanic(value) | Type guard for Panic |

Type Helpers

| Type | Description | | ------------------------ | ---------------------------- | | InferOk<R> | Extract Ok type from Result | | InferErr<R> | Extract Err type from Result | | SerializedResult<T, E> | Plain object form of Result | | SerializedOk<T> | Plain object form of Ok | | SerializedErr<E> | Plain object form of Err |

Agents & AI

better-result ships with portable SKILL.md skills instead of an interactive CLI.

Available skills

  • better-result-adopt — adopt better-result in an existing codebase
  • better-result-migrate-v2 — migrate v1 TaggedError usage to the v2 API

These skills are designed to work with SKILL.md-compatible agents and skills.sh-compatible tooling.

Install with skills.sh-compatible tooling

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

To install globally without prompts:

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

Manual installation

If your agent does not support skills.sh installation, copy one of these directories into the agent's skills folder:

  • skills/better-result-adopt/
  • skills/better-result-migrate-v2/

What the skills do

better-result-adopt guides an agent through:

  • converting try/catch to Result.try / Result.tryPromise
  • defining TaggedError classes for domain errors
  • refactoring nested error handling into Result.gen
  • replacing nullable or sentinel error returns with Result

better-result-migrate-v2 guides an agent through:

  • migrating TaggedError classes from v1 to v2 factory syntax
  • updating constructor call sites to the new object form
  • replacing TaggedError.match* helpers with standalone helpers
  • updating imports and verifying no old API usages remain

Optional source context

For richer AI context in a consuming project:

npx opensrc better-result

See skills/README.md for a concise skill-install reference.

License

MIT