dmmulroy
better-result
TypeScript

Lightweight Result type for TypeScript with generator-based composition.

Last updated Jul 8, 2026
1.6k
Stars
35
Forks
14
Issues
+7
Stars/day
Attention Score
80
Language breakdown
No language data available.
โ–ธ Files click to expand
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"
  },
});

The try callback receives a TryContext with a 1-based attempt number:

const result = await Result.tryPromise(({ attempt }) => fetchWithRetryContext(url, attempt), {
  retry: {
    times: 3,
    delayMs: 100,
    backoff: "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 TaggedError instance, including toJSON() 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 | | AnyTaggedError | Generic TaggedError instance | | 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

๐Ÿ”— More in this category

ยฉ 2026 GitRepoTrend ยท dmmulroy/better-result ยท Updated daily from GitHub