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

ts-safe-result

v2.0.0

Published

A tiny, type-safe Result type for TypeScript with chainable async support. Handle errors as values, not exceptions.

Readme

ts-safe-result

A tiny, type-safe Result<Value, Error> type for TypeScript. Handle errors as values, not exceptions — synchronously and asynchronously.

npm bundle size license

Why?

TypeScript function signatures lie. async function getUser(id: string): Promise<User> tells you the happy path — it says nothing about what goes wrong.

Every modern language has solved this: Rust has Result, Go has error values, Swift has typed throws. JavaScript still has try/catch with unknown errors.

ts-safe-result brings typed errors to TypeScript:

  • Zero dependencies, ~1.3KB gzipped, ESM + CJS, tree-shakable at the top level
  • Sync and asyncResult for synchronous code, ResultAsync for chainable promises
  • Throws stay inside the chainResultAsync transforms catch user-callback errors and convert them to Err, so await always resolves to a Result
  • Familiar APImap, mapErr, andThen, orElse, match, unwrap, tap, combine, partition, fromThrowable
  • Type-safe pattern matching — TypeScript ensures every branch is handled

Read the full philosophy: Stop Writing try/catch — Your TypeScript Errors Deserve Types

Install

npm install ts-safe-result
# or
pnpm add ts-safe-result
# or
yarn add ts-safe-result
# or
bun add ts-safe-result

Requires Node 16+ (uses ES2022 Error cause and other modern features).

Quick Start

import { ok, err, tryCatch, tryAsync } from 'ts-safe-result';
import type { Result } from 'ts-safe-result';

// Create typed results
const success = ok(42);              // Ok<number, never>
const failure = err('Not found');    // Err<never, string>

// Wrap functions that may throw
const parsed = tryCatch(() => JSON.parse(rawInput));
// Result<unknown, Error>

// Chain async pipelines without unwrapping at every step
const email = await tryAsync(() => fetch('/api/user').then(r => r.json()))
  .map(user => user.email)
  .flatMap(validateEmail)
  .unwrapOr('[email protected]');

API

Constructors

ok(value) — Create a successful result

const user = ok({ name: 'Maryan', role: 'admin' });
// Ok<{ name: string; role: string }, never>

err(error) — Create a failed result

const failure = err({ code: 'NOT_FOUND', message: 'User does not exist' });
// Err<never, { code: string; message: string }>

Methods

Every Result exposes the same methods. The transformation methods are no-ops on the branch they don't apply to, so chains short-circuit naturally and Ok/Err instances are returned unchanged when nothing happens (zero allocations on the no-op path).

.map(transform) — Transform the success value

ok(2).map(count => count * 3);
// Ok(6)

err('not found').map(count => count * 3);
// Err('not found') — transform is skipped

.mapErr(transform) — Transform the error value

err('timeout').mapErr(message => new Error(message));
// Err(Error('timeout'))

ok(42).mapErr(message => new Error(message));
// Ok(42) — transform is skipped

.flatMap(transform) / .andThen(transform) — Chain dependent results

andThen is an alias for flatMap — use whichever name feels more familiar. Use either when the transform itself returns a Result:

const parseAge = (input: string): Result<number, string> => {
  const age = parseInt(input, 10);
  return isNaN(age) ? err('Invalid number') : ok(age);
};

ok('25').flatMap(parseAge);     // Ok(25)
ok('abc').andThen(parseAge);    // Err('Invalid number')
err('missing').flatMap(parseAge); // Err('missing') — skipped

.orElse(recover) — Recover from an error

The mirror of flatMap: runs only on Err, lets you fall back to another Result:

const config = readConfig('./app.json')
  .orElse(() => readConfig('./default.json'))
  .orElse(() => ok(builtInDefaults));
// Tries each source in turn, succeeds with the first available config.

.tap(sideEffect) — Inspect the value without transforming it

Useful for logging or debugging within a chain:

const user = await tryAsync(() => fetchUser(id))
  .tap(user => console.log('Fetched:', user.name))
  .map(user => user.email);
// Side effect runs, but the Result stays unchanged

.tapErr(sideEffect) — Inspect the error without transforming it

Useful for error reporting within a chain:

const config = tryCatch(() => JSON.parse(rawConfig))
  .tapErr(error => Sentry.captureException(error))
  .unwrapOr(defaultConfig);
// Error gets reported, but the chain continues

.match({ ok, err }) — Exhaustive pattern matching

Handle both cases explicitly — TypeScript ensures you cover both:

const message = result.match({
  ok:  user  => `Welcome, ${user.name}`,
  err: error => `Login failed: ${error.message}`,
});

.unwrap() — Extract value or throw

Throws an Error whose cause field preserves the original error value, so structured errors aren't flattened to [object Object]:

ok(42).unwrap();          // 42
err('broken').unwrap();   // throws Error('broken')

try {
  err({ code: 'NOT_FOUND' }).unwrap();
} catch (e) {
  console.log(e.cause); // { code: 'NOT_FOUND' }
}

.unwrapOr(fallback) — Extract value or use a fallback

ok(42).unwrapOr(0);          // 42
err('broken').unwrapOr(0);   // 0

.unwrapOrElse(handleError) — Extract value or compute a fallback

err('broken').unwrapOrElse(error => `recovered from: ${error}`);
// 'recovered from: broken'

.expect(message) — Extract value or throw with a custom message

Better than unwrap() when debugging — the message points to the failing call site, and the original error is preserved via Error.cause:

const apiKey = readEnv('API_KEY').expect('API_KEY env var must be set');
// throws Error('API_KEY env var must be set: <original message>')
//   with .cause set to the original error

.isOk() / .isErr() — Type guards for narrowing

const result: Result<User, AppError> = await getUser('123');

if (result.isOk()) {
  console.log(result.value.name);  // ✅ TypeScript knows .value exists
}

if (result.isErr()) {
  console.log(result.error.code);  // ✅ TypeScript knows .error exists
}

Async — ResultAsync

ResultAsync is a chainable wrapper around Promise<Result<Value, Error>>. It implements PromiseLike, so await resolves directly to a plain Result.

This is the key difference from naive Promise<Result>: you can chain before awaiting, eliminating the unwrap-rewrap-await dance.

import { tryAsync, ok, err } from 'ts-safe-result';

const email = await tryAsync(() => fetchUser(id))
  .map(user => user.email)              // sync transform on the value
  .flatMap(validateEmail)               // chain into another Result
  .tapErr(e => log.error(e))            // observe failures
  .mapErr(e => ({ code: 'INVALID' }))   // normalize error type
  .unwrapOr('[email protected]');     // resolve with a default

Throws inside transforms — what happens?

ResultAsync's chainable methods (map, mapErr, flatMap, andThen, orElse) catch both synchronous throws and promise rejections from the user-supplied callback and convert them into an Err. The error type widens to include the standard Error so the chain stays inside Result-land instead of producing an unhandled rejection.

const result = await tryAsync(() => Promise.resolve(42))
  .map(n => { throw new Error('boom'); });
// result is Err(Error('boom')) — NOT a thrown exception
// Type: Result<number, Error>

tap and tapErr go further: side-effect failures are silently swallowed, since logging or analytics errors must never poison the pipeline.

await tryAsync(() => fetchUser(id))
  .tap(user => { throw new Error('logger broken'); }) // silently ignored
  .map(user => user.email);
// Chain continues uninterrupted, the original Result is preserved

If a user-supplied errorFn itself throws, the chain falls back to wrapping the original caught value as an Error.

tryAsync(execute, errorFn?) — Wrap an async function that may throw

execute() is invoked synchronously — if it throws before returning a promise, the throw is still captured.

const post = tryAsync(async () => {
  const response = await fetch('/api/posts/1');
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
});
// ResultAsync<unknown, Error> — chainable

const title = await post.map(p => p.title).unwrapOr('Untitled');

// With a custom error type:
type ApiError = { kind: 'network'; cause: unknown };
const user = tryAsync(
  () => fetch('/api/user').then(r => r.json()),
  (caught): ApiError => ({ kind: 'network', cause: caught }),
);
// ResultAsync<unknown, ApiError | Error>

fromPromise(promise, errorFn?) — Wrap a promise directly

const status = await fromPromise(fetch('/api/health'))
  .map(res => res.status)
  .unwrapOr(0);

okAsync(value) / errAsync(error) — Already-resolved shortcuts

Lighter-weight than ResultAsync.fromResult(ok(value)):

import { okAsync, errAsync } from 'ts-safe-result';

const value = await okAsync(42)
  .map(n => n * 2)
  .unwrap(); // 84

const fallback = await errAsync<string>('primary failed')
  .orElse(() => okAsync('backup'))
  .unwrap(); // 'backup'

ResultAsync.fromSafePromise(promise) — Promise that won't reject

const start = ResultAsync.fromSafePromise(
  Promise.resolve(performance.now()),
);
// ResultAsync<number, never>

.catch(handler) / .finally(cleanup) — Promise-style escape hatches

ResultAsync plays nicely with code that expects a Promise:

const result = await tryAsync(() => fetchUser(id))
  .finally(() => spinner.stop());

ResultAsync exposes the same chainable methods as Result (map, mapErr, flatMap, andThen, orElse, tap, tapErr, match, unwrap, unwrapOr, unwrapOrElse, expect). Mappers may return either sync values or promises — both are awaited automatically.


Utilities

tryCatch(execute, errorFn?) — Wrap a sync function that may throw

const config = tryCatch(() => JSON.parse(rawConfigString));
// Result<any, Error>

// With a custom error mapper:
type ConfigError = { code: 'PARSE_ERROR'; cause: unknown };
const parsed = tryCatch(
  () => JSON.parse(rawConfigString),
  (caught): ConfigError => ({ code: 'PARSE_ERROR', cause: caught }),
);
// Result<any, ConfigError>

Note: Synchronous Result methods (Result.map, Result.flatMap, etc.) do not catch throws from their callbacks — only ResultAsync does. In sync code, throws are right at the call site, so wrap with tryCatch if you need to convert them. This matches Rust/idiomatic-Result behavior.

fromThrowable(fn, errorFn?) — Lift a throwing function into a Result-returning one

Higher-order version of tryCatch. The original function's signature is preserved:

const safeJsonParse = fromThrowable(JSON.parse);
safeJsonParse('{"a":1}'); // Result<any, Error>
safeJsonParse('not json'); // Err(SyntaxError)

// With a custom error mapper
const safeReadFile = fromThrowable(
  fs.readFileSync,
  (caught) => ({ code: 'IO_ERROR', cause: caught } as const),
);

fromNullable(value, error) — Convert nullable to a Result

const apiKey = fromNullable(
  process.env.API_KEY,
  'API_KEY environment variable is not set',
);
// Result<string, string>

collect(results) — Combine an array of Results, short-circuit on error

Succeeds only if every Result is Ok. Returns the first error otherwise. Best for homogeneous arrays:

const users = collect([ok(alice), ok(bob), ok(charlie)]);
// Ok([alice, bob, charlie])

const users = collect([ok(alice), err('Bob not found'), ok(charlie)]);
// Err('Bob not found')

combine(tuple) — Combine a heterogeneous tuple of Results

Type-aware: each slot in the input tuple preserves its specific value/error types in the output. Use this when the Results have different value types:

const fetchUser:  () => Result<User, ApiError>;
const fetchPosts: () => Result<Post[], ApiError>;
const fetchTags:  () => Result<Tag[], ApiError>;

const combined = combine([fetchUser(), fetchPosts(), fetchTags()]);
// Result<[User, Post[], Tag[]], ApiError>

combineWithAllErrors(tuple) — Combine, accumulating ALL errors

Mirror of combine, but never short-circuits — collects every error in input order. Perfect for form validation:

const result = combineWithAllErrors([
  validateName(form.name),
  validateEmail(form.email),
  validateAge(form.age),
]);

result.match({
  ok:  ([name, email, age]) => save({ name, email, age }),
  err: errors => showAllValidationErrors(errors),
  // Err(["name too short", "invalid email"]) — every problem reported
});

partition(results) — Split an array of Results into oks and errs

Unlike collect, never short-circuits — every Result is inspected:

const [users, errors] = partition([
  ok(alice),
  err('Bob not found'),
  ok(charlie),
  err('Dave not found'),
]);
// users  = [alice, charlie]
// errors = ['Bob not found', 'Dave not found']

isResult(value) / isOk(value) / isErr(value) — Standalone type guards

When you have an unknown and need to ask "is this a Result?":

function handle(value: unknown) {
  if (isOk(value)) {
    console.log('got an Ok:', value.value);
  } else if (isErr(value)) {
    console.log('got an Err:', value.error);
  }
}

isResult works across realms (Workers, iframes) and across multiple copies of the library on the same page, thanks to a globally-registered Symbol brand.


Real-World Patterns

Type-safe API client

Define your error types as a discriminated union, then use .match() to handle every case:

type ApiError =
  | { type: 'network'; message: string }
  | { type: 'not_found' }
  | { type: 'validation'; fields: string[] };

function fetchUser(userId: string) {
  return tryAsync(
    () => fetch(`/api/users/${userId}`),
    (caught): ApiError => ({ type: 'network', message: String(caught) }),
  )
    .flatMap(res => {
      if (res.status === 404) return err<ApiError>({ type: 'not_found' });
      if (!res.ok) return err<ApiError>({ type: 'network', message: `HTTP ${res.status}` });
      return ok(res);
    })
    .map(res => res.json() as Promise<User>);
}

// Every error is visible in the type signature and handled explicitly
const userResult = await fetchUser('123');

userResult.match({
  ok: user => renderProfile(user),
  err: error => {
    switch (error.type) {
      case 'not_found':   return render404();
      case 'network':     return renderNetworkError(error.message);
      case 'validation':  return renderFieldErrors(error.fields);
    }
  },
});

Chaining transformations

Chain multiple operations — the pipeline short-circuits on the first error:

const userEmail = tryCatch(() => JSON.parse(rawPayload))
  .map(payload => payload.users)
  .flatMap(users =>
    fromNullable(
      users.find(user => user.id === targetId),
      'User not found in payload',
    ),
  )
  .map(user => user.email)
  .unwrapOr('[email protected]');

Recovering from failures with orElse

Try a primary source, fall back to a secondary, then a default:

const config = await fromPromise(fetchRemoteConfig())
  .orElse(() => fromPromise(readLocalConfig()))
  .orElse(() => okAsync(builtInDefaults))
  .unwrap(); // safe — at least one branch always succeeds

Form validation with combineWithAllErrors

Show every validation error at once instead of one at a time:

const result = combineWithAllErrors([
  required(form.name).mapErr(() => 'Name is required'),
  email(form.email).mapErr(() => 'Email must be valid'),
  minAge(form.age, 18).mapErr(() => 'Must be 18+'),
]);

result.match({
  ok:  ([name, email, age]) => createAccount({ name, email, age }),
  err: errors => setFormErrors(errors), // ['Email must be valid', ...]
});

With Zod validation

import { z } from 'zod';
import { fromThrowable } from 'ts-safe-result';

const UserSchema = z.object({ name: z.string(), age: z.number() });
const parseUser = fromThrowable(
  (data: unknown) => UserSchema.parse(data),
  (caught) => caught as z.ZodError,
);

const validatedUser = parseUser(requestBody);

validatedUser.match({
  ok:  user     => saveToDatabase(user),
  err: zodError => respondWithErrors(zodError.flatten()),
});

Wrapping a third-party library with fromThrowable

import jwt from 'jsonwebtoken';

const verifyJwt = fromThrowable(
  (token: string, secret: string) => jwt.verify(token, secret) as JwtPayload,
  (caught): AuthError =>
    caught instanceof jwt.TokenExpiredError
      ? { kind: 'expired' }
      : { kind: 'invalid' },
);

const payload = verifyJwt(token, secret); // Result<JwtPayload, AuthError>

Migration from v1

v2 is backward-compatible at the call-site level. Two behavioral changes worth knowing:

tryAsync and fromPromise now return ResultAsync

Existing await tryAsync(...) calls keep working unchanged because ResultAsync is PromiseLike. The win is that you can now chain .map, .flatMap, etc. before awaiting.

// v1 — still works in v2
const result = await tryAsync(() => fetchUser(id));
const email = result.map(u => u.email).unwrapOr('none');

// v2 — new capability
const email = await tryAsync(() => fetchUser(id))
  .map(u => u.email)
  .unwrapOr('none');

ResultAsync transforms catch user-callback throws

In v2, ResultAsync.map / mapErr / flatMap / andThen / orElse catch throws from the user callback and place the error in the chain instead of producing an unhandled rejection. The error type widens to include the standard Error. This means await always resolves to a Result, never throws.

If you previously relied on throws inside .map() propagating as exceptions, wrap with tryCatch / tryAsync instead.

unwrapOr accepts any fallback type

// v1: fallback had to match Value type
ok(42).unwrapOr(0); // ✓
ok(42).unwrapOr('zero'); // ✗ type error

// v2: fallback can be any type, return widens to Value | Fallback
ok(42).unwrapOr('zero'); // ✓ — type is number | string

New in v2

ResultAsync, okAsync, errAsync, andThen, orElse, expect, fromThrowable, partition, combine, combineWithAllErrors, isResult, isOk, isErr, .catch() / .finally() on ResultAsync.


Comparison

| Feature | ts-safe-result | neverthrow | ts-results | fp-ts | | -------------------- | -------------- | ---------- | ---------- | ---------- | | Bundle size (gzip) | ~1.3KB | ~3KB | ~2KB | ~15KB | | Tree-shakable | Yes (top level) | Partial | No | Yes | | Async chaining | Yes | Yes | No | TaskEither | | .match() | Yes | Yes | Yes | Via fold | | andThen / orElse | Yes | Yes | Partial | Yes | | combine for tuples | Yes | Yes | No | Yes | | Catches thrown errors in async transforms | Yes | No | n/a | Yes | | Cross-realm isResult | Yes | No | No | n/a | | Learning curve | Minimal | Low | Low | Steep | | Dependencies | 0 | 0 | 0 | 0 |

Philosophy

This library follows three principles:

  1. Errors are values, not exceptions. If a function can fail, the failure should be part of the return type.
  2. Impossible states should be impossible. A Result is either Ok or Err — never both, never neither.
  3. Simple beats clever. No monads, no functors, no category theory. Just ok, err, and methods you already know.

License

MIT