Skip to content

Lightweight Result type for TypeScript with generator-based composition.

License

Notifications You must be signed in to change notification settings

dmmulroy/better-result

Repository files navigation

better-result

Lightweight Result type for TypeScript with generator-based composition.

Install

New to better-result?

npx better-result init

Upgrading from v1?

npx better-result migrate

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
const result = fetchUser(id).match({
  ok: (user) => Result.ok(user),
  err: (e) => (e._tag === "NotFoundError" ? Result.ok(defaultUser) : Result.err(e)),
});

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 { 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

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.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
.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

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 skills for AI coding agents (OpenCode, Claude Code, Codex).

Quick Start

npx better-result init

Interactive setup that:

  1. Installs the better-result package
  2. Optionally fetches source code via opensrc for better AI context
  3. Installs the adoption skill + /adopt-better-result command for your agent
  4. Optionally launches your agent

What the skill does

The /adopt-better-result command guides your AI agent through:

  • Converting try/catch to Result.try/tryPromise
  • Defining TaggedError classes for domain errors
  • Refactoring to generator composition
  • Migrating null checks to Result types

Supported agents

Agent Config detected Skill location
OpenCode .opencode/ .opencode/skill/better-result-adopt/
Claude .claude/, CLAUDE.md .claude/skills/better-result-adopt/
Codex .codex/, AGENTS.md .codex/skills/better-result-adopt/

Manual usage

If you prefer not to use the interactive CLI:

# Install package
npm install better-result

# Add source for AI context (optional)
npx opensrc better-result

# Then copy skills/ directory to your agent's skill folder

License

MIT