back to posts
coding

Why I want to start using neverthrow

A look at neverthrow's error handling patterns and why type-safe error handling beats exceptions for production TypeScript code.

Alex MillerOctober 23, 2025
Why I want to start using neverthrow

Why I Started Looking for Better Error Handling

Coming from Rust development, I'd gotten used to the Result type - where errors are part of your function signature and the compiler forces you to handle them. Then I'd switch back to TypeScript and immediately feel like I was walking on a tightrope without a safety net.

The exception model in TypeScript (and JavaScript) has fundamental problems that kept biting me:

  • Silent failures: Exceptions can bubble up through layers of code you didn't expect

  • Invisible error paths: Function signatures don't tell you what can go wrong

  • Forgotten error handling: Easy to ignore or forget try/catch blocks

  • Poor debugging experience: Generic error messages that don't help at 2 AM

I'd spent too many nights debugging production failures caused by uncaught exceptions to accept "just be more careful with try/catch" as a solution. There had to be a better way.

My Attempts: From Exceptions to Custom Libraries

My first attempt was building something like Rust's thiserror crate - rich error types with good ergonomics. I built @quotientjs/error with a pretty sophisticated API: factory functions for creating typed errors, message templating, schema validation, type guards - the whole nine yards.

Then I combined it with Zod for validation - great error messages, structured data, everything I wanted. But I still had a fundamental problem:

// My custom error factory approach
const AppError = createErrorFactory({
  ValidationError: [
    "Invalid {field}: {message}",
    { field: String, message: String, attemptedValue: Object }
  ],
  NotFound: [
    "Resource {resourceType} with ID {id} not found",
    { resourceType: String, id: String }
  ]
});

// Usage - still throwing exceptions!
function validateAndProcess(data: unknown) {
  const schema = z.object({ id: z.string(), name: z.string() });
  
  try {
    const validated = schema.parse(data);
    return processData(validated);
  } catch (error) {
    if (error instanceof ZodError) {
      throw AppError.ValidationError({
        field: 'data',
        message: error.message,
        attemptedValue: data
      });
    }
    throw error; // Still escape hatch for unknown errors
  }
}

I had nice error types and good validation, but I was still throwing exceptions. Function signatures didn't tell you what could go wrong. The compiler couldn't force me to handle errors. I was still playing exception whack-a-mole.

Plus, the API was honestly cumbersome. The factory pattern felt overengineered, IntelliSense struggled with the dynamically spread data properties, and despite all the type safety, you could still just ignore errors entirely. The sophisticated error types were meaningless if developers could forget to catch them.

What I was missing was Rust's core insight: make errors part of the type signature. In Rust, a function that can fail returns Result<T, E>, and you literally cannot ignore it - the compiler won't let you.

Discovering neverthrow: Finally, the Result Type for TypeScript

That's when I stumbled across neverthrow. Finally, someone had built Rust's Result type for TypeScript. The core idea is simple: instead of throwing exceptions, functions return Result<T, E> where T is success and E is error.

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

// Before: throws exceptions
function parseUser(json: string): User {
  const data = JSON.parse(json); // Could throw
  return validateUser(data);     // Could also throw
}

// After: returns Result
function parseUser(json: string): Result<User, string> {
  try {
    const data = JSON.parse(json);
    return ok(data);
  } catch {
    return err('Invalid JSON');
  }
}

Now when I call this function, TypeScript forces me to handle both cases:

const result = parseUser(jsonString);
// Can't access result.value - compiler error!
// Must handle both success and failure:

result.match(
  user => console.log('Success:', user.name),
  error => console.log('Failed:', error)
);

// Or use other Result methods like map, andThen, etc.

This was the lightbulb moment. I could finally have Rust-style error handling in TypeScript, with the compiler as my ally instead of my enemy.

Three Patterns That Make This Compelling

Playing around with neverthrow, I've found three main patterns that really sell me on this approach:

1. Simple Enum Errors for Binary Conditions

When you just need to distinguish between different failure modes without carrying extra context:

type AuthError = 'InvalidCredentials' | 'AccountLocked' | 'SessionExpired';

function authenticate(username: string, password: string): Result<User, AuthError> {
  if (!password || password.length < 8) {
    return err('InvalidCredentials');
  }
  if (username === 'locked_user') {
    return err('AccountLocked');
  }
  return ok({ id: '123', name: username });
}

TypeScript's exhaustive checking means you can't forget to handle a case. Perfect for binary conditions where you just need to know "what went wrong" without needing the "why" or "how."

2. Rich Error Context with Union Types

When errors need to carry information about what went wrong, use discriminated unions:

type ValidationError = {
  type: 'ValidationError';
  field: string;
  message: string;
  attemptedValue: unknown;
};

type NotFoundError = {
  type: 'NotFoundError';
  resource: string;
  id: string;
};

type AppError = ValidationError | NotFoundError;

Now you get rich debugging information (field names, attempted values, resource IDs) while maintaining type safety. TypeScript narrows the error type based on the discriminator, giving you autocomplete for error-specific fields.

3. Handling Multiple Operations

When processing multiple operations, neverthrow gives you two main strategies:

Result.combine() for fail-fast behavior - stops at the first error:

const operations = [validatePayment(), checkInventory(), processShipping()];

Result.combine(operations).match(
  ([payment, inventory, shipping]) => processOrder(payment, inventory, shipping),
  error => handleOrderFailure(error) // Single error, failed fast
);

Result.combineWithAllErrors() to collect every failure:

const formValidations = [validateEmail(email), validatePassword(password), validateAge(age)];

Result.combineWithAllErrors(formValidations).match(
  ([email, password, age]) => createAccount({ email, password, age }),
  errors => displayAllValidationErrors(errors) // Array of all errors
);

Why This Actually Matters

This isn't just academic - these patterns solve real problems I've been fighting for years. The debugging experience alone is transformative. Instead of:

"TypeError: Cannot read property 'id' of undefined"

You get:

"ValidationError on field 'email': Must be a valid email address. You provided: 'not-an-email'"

More importantly, refactoring becomes safer. Add a new error case to your union type? TypeScript forces you to handle it everywhere it might occur. No more silent bugs from forgotten edge cases.

The compiler transforms from something you work around into something that actively helps you write better code. Function signatures become contracts that tell you exactly what can go wrong.

Worth Trying

I haven't shipped neverthrow to production yet, but after playing with it for a few days, I'm convinced this is the right direction. The patterns feel natural, the type safety is exactly what I've been missing, and the debugging story is dramatically better.

If you're tired of JavaScript's exception model and want something that feels more like Rust's Result type, neverthrow is worth exploring. Start small - try it on your next validation function or API call. See how it feels to have the compiler force you to think through failure modes.

Your future debugging sessions might thank you for it.

If you want to see these patterns in action, I built an interactive demo that walks through all three approaches: neverthrow patterns demo. You can try different inputs and see how each error handling pattern responds.