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!