I tried every TypeScript Result library

A practical guide to choosing the right Result library for TypeScript

Result Libraries

In the previous article, we explored why TypeScript’s invisible exceptions are problematic and how Result<T, E> types can make error handling explicit and type-safe. Now comes the practical question: which library should you actually use?

The TypeScript ecosystem offers several Result implementations, each with different trade-offs. Let’s explore them from simplest to most powerful, so you can choose the right tool for your current needs.

Training Wheels: Boxed

In my own journey exploring functional programming in TypeScript, I found it helpful to start off with an existing library rather than rolling my own Result type.

With that in mind, I found Boxed to have the smallest and simplest API surface area and a useful page explaining the basic concepts with visuals.

import { Result, Future } from '@swan-io/boxed';

// Without Result types - defensive programming
const welcomeUserBasic = async (userId: string) => {
  try {
    const user = await fetch(`/api/users/${userId}`).then(res => res.json());
    if (!user) {
      console.log("User not found");
      return;
    }

    if (!user.email) {
      console.log("User has no email");
      return;
    }

    await sendEmail(user.email, "Welcome!");
    console.log("Welcome email sent successfully");
  } catch (error) {
    console.log("Something failed:", error);
  }
};

// With Boxed
const fetchUser = (id: string): Future<User, string> => 
  Future.fromPromise(fetch(`/api/users/${id}`).then(res => res.json()))
    .mapError(() => "Failed to fetch user");

const validateEmail = (user: User): Result<string, string> =>
  user.email 
    ? Result.Ok(user.email)
    : Result.Error("User has no email");

const sendWelcomeEmail = (email: string): Future<string, string> =>
  Future.fromPromise(sendEmail(email, "Welcome!"))
    .mapError(() => "Failed to send email")
    .map(() => "Welcome email sent successfully");

const welcomeUser = (userId: string): Future<string, string> =>
  fetchUser(userId)
    .mapOkToResult(validateEmail)    // Apply sync validation to async result
    .flatMapOk(sendWelcomeEmail);    // Chain async operation

Boxed’s chaining feels natural if you’re coming from Promise-based code or array methods. The API is minimal but covers the essentials:

  • Result.Ok(value) and Result.Error(error) for construction
  • .map() for transforming success values
  • .flatMap() for chaining operations that return Results
  • .match() for handling both cases
  • .getOr() for providing defaults

It’s perfect for teams new to functional programming who want to dip their toes in without a steep learning curve.

Other Libraries Worth Considering

True Myth

If you decide to try out other libraries, check out true-myth, which provides an andThen helper so you don’t have to distinguish between map vs flatMap. This can reduce cognitive overhead, although I find it helpful to still be aware of why both exist (once again, see Boxed’s visuals).

import { Result, Task } from 'true-myth';

// Same example with true-myth
const fetchUser = (id: string): Task<User, string> => 
  Task.fromPromise(fetch(`/api/users/${id}`).then(res => res.json()))
    .mapErr(() => "Failed to fetch user");

const validateEmail = (user: User): Result<string, string> =>
  user.email 
    ? Result.ok(user.email)
    : Result.err("User has no email");

const sendWelcomeEmail = (email: string): Task<string, string> =>
  Task.fromPromise(sendEmail(email, "Welcome!"))
    .mapErr(() => "Failed to send email")
    .map(() => "Welcome email sent successfully");

const welcomeUser = (userId: string): Task<string, string> =>
  fetchUser(userId)
    .andThen(user => {
      const emailResult = validateEmail(user);
      return emailResult.isOk 
        ? sendWelcomeEmail(emailResult.value)
        : Task.reject(emailResult.error);
    });

Both Boxed and true-myth also offer an Option/Maybe type, which simplifies dealing with undefined, null, and custom falsy values. By mapping on an Option, your code can focus on the happy path via short-circuiting.

// Option shines with complex nullable scenarios
const extractUserAvatar = (user: User): Option<string> => 
  Option.fromNullable(user.profile?.avatar)
    .filter(url => url !== "")                    // Empty string = None
    .filter(url => !url.includes("default.png")) // Ignore default avatars
    .filter(url => url.startsWith("https://"));  // Only secure URLs

// Option provides clean defaults, then integrates with Result pipeline
const welcomeUser = (userId: string): Future<string, string> =>
  fetchUser(userId)
    .mapOkToResult(user => {
      const avatar = extractUserAvatar(user).getOr("https://via.placeholder.com/150");
      const emailTemplate = `Welcome! Your avatar: ${avatar}`;
      
      return validateEmail(user)
        .map(email => ({ email, template: emailTemplate }));
    })
    .flatMapOk(({ email, template }) => sendWelcomeEmail(email, template));

NeverThrow

If you don’t need the Option type (which is admittedly less necessary with JavaScript’s built-in optional chaining), NeverThrow focuses purely on Result types. All three libraries mentioned have basically the same API, just with different casing or naming preferences (Future vs Task vs ResultAsync).

import { ok, err, Result, ResultAsync } from 'neverthrow';

// Same example with neverthrow's async handling
const fetchUser = (id: string): ResultAsync<User, string> => 
  ResultAsync.fromPromise(
    fetch(`/api/users/${id}`).then(res => res.json()),
    () => "Failed to fetch user"
  );

const validateEmail = (user: User): Result<string, string> =>
  user.email 
    ? ok(user.email)
    : err("User has no email");

const sendWelcomeEmail = (email: string): ResultAsync<string, string> =>
  ResultAsync.fromPromise(
    sendEmail(email, "Welcome!"),
    () => "Failed to send email"
  ).map(() => "Welcome email sent successfully");

const welcomeUser = (userId: string): ResultAsync<string, string> =>
  fetchUser(userId)
    .andThen(user => {
      const emailResult = validateEmail(user);
      return emailResult.isOk() 
        ? sendWelcomeEmail(emailResult.value)
        : errAsync(emailResult.error);
    });

The Big Guns: Effect

But ultimately, if you are writing for production, I suggest Effect.

Yes, it feels like a big commitment if you’ve never come across it before. But if you really intend to write code in a robust, scalable way, Effect offers multiple advantages:

  1. It has modules for things you’d otherwise have to hand-roll
  2. Everything interconnects and plays nicely within the Effect ecosystem
  3. Plus, if you don’t love chaining methods, Effect gives you async/await-like syntax with generator functions:
import { Effect } from 'effect';

// Same example with Effect's unified approach
const fetchUser = (id: string): Effect.Effect<User, string> =>
  Effect.tryPromise({
    try: () => fetch(`/api/users/${id}`).then(res => res.json()),
    catch: () => "Failed to fetch user"
  });

const validateEmail = (user: User): Effect.Effect<string, string> =>
  user.email 
    ? Effect.succeed(user.email)
    : Effect.fail("User has no email");

const sendWelcomeEmail = (email: string): Effect.Effect<string, string> =>
  Effect.tryPromise({
    try: () => sendEmail(email, "Welcome!"),
    catch: () => "Failed to send email"
  }).pipe(Effect.map(() => "Welcome email sent successfully"));

// Generator syntax feels like async/await so no need to chain!
const welcomeUser = (userId: string): Effect.Effect<string, string> =>
  Effect.gen(function* () {
    const user = yield* fetchUser(userId);      // async
    const email = yield* validateEmail(user);   // sync
    const message = yield* sendWelcomeEmail(email); // async
    return message;
  });

Effect.runPromise(welcomeUser("123"))
  .then(console.log)
  .catch(console.error);

In addition, because of the async/sync divide in JavaScript (the “red vs blue” problem), other libraries need separate Result and ResultAsync/Task/Future types. In Effect, everything is just an Effect, whether sync or async (you’ll have to wrap non-Effect operations with try or tryPromise respectively, but they both still return the same Effect type).

Migration Strategy

However, it is certainly a simpler learning curve to get your head wrapped around Result types without also learning Effect’s broader ecosystem. So, you might want to introduce Boxed to one part of your code. Then you can migrate to Effect once you’re comfortable with the concepts.

Speaking from personal experience, this is exactly what I did: I started with Boxed to get comfortable with the mental model. Once I had the hang of it, I tried out Effect and discovered it replaced multiple unrelated parts of my codebase, e.g. Zod for validation, custom retry logic, pattern-matching, logging utilities, and more!

The beauty of this approach is that the mental model transfers perfectly. Once you understand how to think in terms of Boxed values and explicit error handling, upgrading to more powerful tools becomes natural as your needs grow.

The important thing is to start somewhere. TypeScript’s invisible exceptions won’t fix themselves, but Result types, regardless of which library you choose, will make your code more reliable from day one.

Next up: we’ll dive deeper into Effect and explore when it’s worth the investment for your projects.