Practically Safe TypeScript Using Neverthrow

April 9, 2025

When do you reach for throw in JavaScript? When you want to surface an error, right?

throw conveniently escapes the stack and travels to whatever catches it, bubbling up to find that place in the stack. It doesn't care about function scope or promises - which return certainly does. That's its superpower but also its Achilles' heel. When you try-catch, you’ve lost type guarantees - and on top of that JavaScript allows you to throw anything so you don’t even know if it’s an instanceof Error! Enabling noImplicitAny in tsconfig.json encourages developers to think about this upfront and make fewer assumptions.

try {
  something()
} catch(error) {
  error // `unknown`!
}

So throw is really all about control flow — being able to go "nuclear" in some code path. A good use case is how Next.js App Router does 404 errors:

import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
import { notFound } from 'next/navigation';
 
export default async function Page(props: { params: Promise<{ id: string }> }) {
  const params = await props.params;
  const id = params.id;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);
 
  if (!invoice) {
    notFound();
  }
 
  // ...
}

The type signature here is notFound(): never. This means calling notFound stops the code flow right in its tracks just like a throw would - because it is, in fact, a throw.

When dealing with a TypeScript codebase, throwing should be considered somewhat of a "rage quit" — a last resort, reserved for rare special cases, for when the convenience escaping upwards towards the next try-catch outweighs the type unsafety and implicitness. In the majority of cases instead of throwing, we can actually leverage TypeScript's explicitness and simply return errors - and there's a long tradition of actually having errors treated as first class "returnable" citizens in programming languages. neverthrow brings this to TypeScript. Any failure the function can't recover from becomes an error or discriminated union of error results. This is colloquially denoted in the world of safe code as a Result<T, E> — where T is the happy path result and E is the typed error.

In a codebase with Result return types all the way down, we not only force ourselves to deal with errors upfront but we unlock some exciting functional programming patterns.

neverthrow lets you embed safety, explicit errors, and ergonomic error handling into your codebase. It's not an all-or-nothing affair — dipping in and out of neverthrow code and gradually adopting it for your codebase is quite easy.

For example, we might have created neverthrow wrappers for fetch and zod — here's how we would chain them together:

const result = (id: string) =>  
  safeFetch(`/users/${id}`)  // 👷🏻 Network worker: "I'll get raw data"  
    .andThen(safeZodParse(userSchema))  // 🕵🏻‍♂️ Validator: "I'll check quality"
// ^ result: Result<User, FetchError | ZodError>

if (result.isErr()) {
  switch (error.type) {  
    // oh dear - error types are unionizing 📢✊🏻
    // their slogan is probably "no error type representation without union discrimination!"
    case 'network': retryWithToast(error.error); break;  //  these ...
    case 'http': trackAnalytics(error.status); break;    // ... three
    case 'parse': logError(error.error); break;           // ... come from `safeFetch`
    case 'zod': showFormErrors(error.errors); break;     // ... but this one from `safeZodParse`!
  }
} else {
  displayUser(user);
}

safeZodParse

This one's simple because zod already has safeParse and inferance helpers:


import { err, ok, type Result } from "neverthrow";
import type { z, ZodError, ZodSchema } from "zod";

// You could throw ZodError directly, but I prefer plain objects with a 
// `type` top-level string constant to handle error unions elegantly
interface ZodParseError<T> {
  type: "zod";
  errors: ZodError<T>;
}

export function safeZodParse<TSchema extends ZodSchema>(
  schema: TSchema
): (data: unknown) => Result<z.infer<TSchema>, ZodParseError<z.infer<TSchema>>> {
  return (data: unknown) => {
    const result = schema.safeParse(data);
    return result.success 
      ? ok(result.data) 
      : err({ type: "zod", errors: result.error });
  };
}

safeFetch

Goal is to grab some JSON - but safely!

import { errAsync, ResultAsync } from "neverthrow";

type FetchError<E> = NetworkError | HttpError<E> | ParseError;

interface NetworkError {
  type: "network";
  error: Error;
}

interface HttpError<E = unknown> {
  type: "http";
  status: number;
  headers: Headers;
  json?: E;
}

interface ParseError {
  type: "parse";
  error: Error;
}

export function safeFetch<T = unknown, E = unknown>(
  input: URL | string,
  init?: RequestInit
): ResultAsync<T, FetchError<E>> {
  // Think of `fromPromise` like an entry point where unsafe code enters
  return ResultAsync.fromPromise(
    fetch(input, init),
    (error): NetworkError => ({
      type: "network",
      error: error instanceof Error ? error : new Error(String(error)),
    }),
  ).andThen((response) => {
    // It's a response but not 2XX
    if (!response.ok) {
      // Parse the JSON as it might contain some useful info
      return ResultAsync.fromSafePromise(
        // Since we don't care about parse errors we can use `fromSafePromise` 
        // and just add a catch, which suppresses JSON parse errors
        response.json().catch(() => undefined)
      ).andThen((json) => err({
        type: "http",
        status: response.status,
        headers: response.headers,
        json: json as E
      }));
    }
    
    // Response is 2XX - return the parsed JSON with an assigned optional type
    return ResultAsync.fromPromise(
      response.json() as Promise<T>,
      (error): ParseError => ({
        type: "parse",
        error: error instanceof Error ? error : new Error(String(error)),
      }),
    );
  });
}

Think of the flow in layers: Network → HTTP → JSON — with a defined error at each stage that's easy to handle through the error type union. If you want to ignore an error, that's up to you — but at least you'll do it explicitly and mindfully.

But why?

The key innovation is making every potential failure mode explicit in the type system while maintaining composability through ResultAsync chaining.

Taking things further with neverthrow

Result and ResultAsync have all kinds of utilities.

.map: like .andThen when you don't need to think about errors

const result = (id: string) =>  
  safeFetch(`/users/${id}`)
    .andThen(safeZodParse(userSchema))
    .map((user) => user.id)  // No need to wrap result in `ok`

.orTee and .andTee: For side effects or success and error tracks respectively

const result = (id: string) =>  
  safeFetch(`/users/${id}`)
    .andThen(safeZodParse(userSchema))
    .orTee(logError)
    .andTee(queueEmailNotification)

Here result remains Result<User, E>. We just used andTee to queue a task. If queuing might throw an error, we'd probably use andThen instead to aggregate a QueueError and handle it gracefully.

And now for the final boss... What if you could write blissful happy-path code without all the isErr checking?

const result = await safeTry(async function* () {
  // Inside `safeTry`, as we `yield`, it automatically unwraps the happy path
  const json = yield* safeFetch("/whoami");
  // ... and we don't need to test for errors
  const zodify = safeZodParse(z.object({ id: z.string() }));
  const user = yield* zodify(json);
  // `safeTry` not only deals with unwrapping results but returns a `Result` of its own
  // we can either rewrap user in an ok or return something else
  return ok({ user, foo: "bar" });
});
if (result.isErr()) {
  result.error
  // ^ FetchError | ZodError
} else {
  result.value
  // ^ {user: User, foo: string}
}

I consider safeTry a magical yet ergonomic alternative to andThen/map/match etc. It lets us push error checking to the side for more focused code with regular const/let assignments — amazing when you think about it!