Home

Error Handling with Neverthrow

TypeScript hides errors from you by default. When you call a function, the return type says nothing about what might go wrong. It's a blind spot in an otherwise excellent type system.

I've spent a lot of time thinking about this. Probably too much time. This post is about neverthrow, the different ways you can structure typed errors, and the pattern I've landed on.

Why Exceptions Are a Problem

Here's a typical service function:

typescript
const getUser = async (id: string): Promise<User> => {
  const user = await db.users.findById(id);
  if (!user) {
    throw new Error("User not found");
  }
  return user;
};

The return type says Promise<User>. That's a lie. It might throw. The caller has no idea. The compiler doesn't care.

For simple scripts, this is fine. For production services with multiple layers (controller → service → repository), it's not. I've watched errors get swallowed in catch blocks that log and move on. I've seen endpoints return { error: "..." } and { message: "..." } and sometimes nothing at all. I've spent hours grepping logs trying to figure out what went wrong where.

How Other Languages Handle This

Java tried to solve this with checked exceptions. You declare throws IOException in your method signature, and the compiler forces callers to handle it. The problem is that checked exceptions are viral and annoying, so everyone just wraps them in RuntimeException and the whole system falls apart. Plus, you end up with broad exception hierarchies that don't carry much meaning. "Something went wrong with IO" isn't very actionable.

Go takes a different approach: return tuples. Every function that can fail returns (value, error). You check if err != nil before using the value.

go
user, err := db.FindUser(id)
if err != nil {
    return nil, err
}

It's explicit, but it's also tedious. Every call site needs the same if err != nil dance. And there's no compiler enforcement. You can just ignore the error and use the value anyway.

Rust does this properly. Every function that can fail returns Result<T, E>. The ? operator propagates errors up the stack. The compiler enforces handling.

rust
fn get_user(id: &str) -> Result<User, DbError> {
    let user = db.find_user(id)?;
    Ok(user)
}

You can't accidentally ignore the error. You can't use the value without handling the error case first. And the ? operator keeps the happy path clean.

TypeScript can't do this natively, but neverthrow gets close.

Neverthrow Basics

Neverthrow gives you Result<T, E>, ok(value), and err(error):

typescript
import { Result, ok, err } from "neverthrow";

const getUser = async (
  id: string
): Promise<Result<User, { type: "NotFound"; message: string }>> => {
  const user = await db.users.findById(id);
  if (!user) {
    return err({ type: "NotFound", message: "User not found" });
  }
  return ok(user);
};

Now the return type is honest. The caller knows what can go wrong:

typescript
const result = await getUser("123");

if (result.isErr()) {
  return { statusCode: 404, body: { error: result.error.message } };
}

return { statusCode: 200, body: result.value };

The question is: how do you structure error types across a system with multiple layers?

Three Ways to Care About Errors

There's a spectrum here. How much ceremony you're willing to tolerate depends on how much you care about encapsulation.

Option 1: Common Errors (Low Effort)

Define a set of common error types. Use them everywhere.

typescript
export namespace CommonError {
  export namespace Type {
    export type NotFound = "NotFound";
    export const NotFound = "NotFound";
    export type AlreadyExists = "AlreadyExists";
    export const AlreadyExists = "AlreadyExists";
  }
}

// Repository
const findById = async (id: string): Promise<Result<User, ServiceError<CommonError.Type.NotFound>>> => { ... };

// Service just passes it through
const getUser = async (id: string): Promise<Result<User, ServiceError<CommonError.Type.NotFound>>> => {
  return userRepository.findById(id);
};

No mapping. Errors flow through layers unchanged.

This is the 80/20 solution. You get typed errors without the headache. The downside is your layers aren't really independent. If you need to change what "NotFound" means at the repository level without touching the service level, you're stuck. But honestly, for most projects, that's fine.

Option 2: ExtractErrors (Sneaky Coupling)

A tempting shortcut: extract error types from your dependencies automatically.

typescript
type ExtractErrors<T> = T extends (...args: never[]) => Promise<Result<unknown, infer E>>
  ? E
  : T extends (...args: never[]) => Result<unknown, infer E>
  ? E
  : never;

type CreateUserErrors = 
  | ExtractErrors<typeof userRepository.create>
  | ServiceError<UserServiceError.Type.ValidationError>;

const createUser = async (email: string): Promise<Result<User, CreateUserErrors>> => {
  // ...
};

Less boilerplate. You don't enumerate every possible error manually.

But here's the catch: implementation details bleed into your interface. If userRepository.create changes its error types, createUser's signature changes. Callers now need to know about repository errors even though they're calling a service function.

This violates interface-driven design. The function's contract is coupled to its implementation. I've used this in smaller projects where the leakage doesn't matter. In larger systems, it's a liability.

Option 3: Explicit Mapping (Maximum Ceremony)

Each function defines its own error types. You map dependency errors into layer-specific errors with switch statements.

typescript
const createUser = async (
  email: string
): Promise<Result<User, ServiceError<UserServiceError.Type.EmailAlreadyExists>>> => {
  const result = await userRepository.create({ email });

  if (result.isErr()) {
    switch (result.error.type) {
      case RepositoryError.Type.UniqueViolation:
        return err({
          type: UserServiceError.Type.EmailAlreadyExists,
          message: UserServiceError.Message.EmailAlreadyExists,
        });
      default:
        assertUnreachable(result.error.type);
    }
  }

  return ok(result.value);
};

The function's interface is entirely its own. Swap Postgres for DynamoDB, the signature doesn't change. Callers have no idea what's underneath.

The cost is verbosity. Every error from every dependency needs handling. It's a lot of switch statements.

Which One?

Common errors work for most things. ExtractErrors is fine when you're moving fast and boundaries are fuzzy. Explicit mapping is for when you actually care about layer independence.

I've drifted toward explicit mapping over time. Not because it's always worth it, but because the discipline forces me to think about what each function is actually promising to its callers.

My Pattern

Here's what I actually use.

Namespaces for Error Types

I use TypeScript namespaces for error definitions. I know, I know. Namespaces are discouraged. But I like them for this:

typescript
export namespace UserServiceError {
  export namespace Type {
    export type UserNotFound = "UserNotFound";
    export const UserNotFound = "UserNotFound";
    export type EmailAlreadyExists = "EmailAlreadyExists";
    export const EmailAlreadyExists = "EmailAlreadyExists";
  }

  export namespace Message {
    export const UserNotFound = "The requested user could not be found";
    export const EmailAlreadyExists = "This email is already registered";
  }
}

UserServiceError.Type.UserNotFound is both a type and a value. I can use it in type annotations and runtime code without separate constants. The IDE highlights it differently. Everything lives together.

You can do the same with objects:

typescript
export const UserServiceError = {
  Type: {
    NotFound: "NotFound" as const,
    InvalidEmail: "InvalidEmail" as const,
  },
  Message: {
    NotFound: "User not found",
    InvalidEmail: "Invalid email address",
  },
} as const;

Or module-level exports. It's a stylistic thing. If your codebase bans non-erasable syntax (like erasableSyntaxOnly), namespaces are off the table. The pattern still works.

Generic Error Shape

All my errors look like this:

typescript
export interface ServiceError<T extends string> {
  type: T;
  message: string;
  details?: Record<string, unknown>;
}

Functions return Result<T, ServiceError<SomeError.Type.X>>. The generic keeps everything type-safe across layers.

mapError Utility

A helper for translating between layers:

typescript
export const mapError = <T extends string, E extends Errors<T>>(
  error: ServiceError<T>,
  errors: E
): Err<never, ServiceError<E["Type"][T]>> => {
  return err({
    type: errors.Type[error.type],
    message: errors.Message[error.type] ?? error.message,
  });
};

Keeps the mapping consistent.

Exhaustive Switches

One more trick:

typescript
export function assertUnreachable(value: never): never {
  throw new Error(`Unreachable code: ${String(value)}`);
}

switch (error.type) {
  case UserServiceError.Type.UserNotFound:
    return { statusCode: 404 };
  case UserServiceError.Type.EmailAlreadyExists:
    return { statusCode: 409 };
  default:
    assertUnreachable(error.type);
}

Add a new error type and forget to handle it? The compiler yells at you. Rust has exhaustive pattern matching built in. This is the TypeScript equivalent.

The Downsides

It's not all upside.

Wrapping everything in Result adds ceremony. Simple functions become longer. Developers unfamiliar with the pattern need ramp-up time. Chaining async Results is awkward (you end up using ResultAsync or writing careful promise chains). Third-party libraries throw exceptions, so you need wrapper functions. And some teams really don't like namespaces.

When I Use This

Backend services with multiple layers. Places where errors have business meaning, where consistent API responses matter, where I'll actually need to debug production issues.

For scripts, CLIs, simple frontend code? Exceptions are fine. I'm not wrapping everything in Result types for a one-off utility.

Conclusion

Neverthrow makes the error path explicit. There are different ways to structure it, from shared common errors (low effort, acceptable coupling) to fully mapped layer-specific errors (high effort, clean interfaces).

I use explicit mapping with namespaces. Not because it's always worth the verbosity, but because it forces me to think about what each function promises. Sometimes that discipline matters. Sometimes it doesn't.

The real value is that every function declares what can go wrong, and the compiler holds you to it.