# Practically Safe TypeScript Using Neverthrow 2025-04-09 ![](https://ss.solberg.is/rCnFb5fV+) 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. ```ts 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: ```tsx 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`](https://github.com/supermacro/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` — 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`](https://github.com/supermacro/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: ```ts const result = (id: string) => safeFetch(`/users/${id}`) // 👷🏻 Network worker: "I'll get raw data" .andThen(safeZodParse(userSchema)) // 🕵🏻‍♂️ Validator: "I'll check quality" // ^ result: Result 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: ```ts 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 { type: "zod"; errors: ZodError; } export function safeZodParse( schema: TSchema ): (data: unknown) => Result, ZodParseError>> { 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! ```ts import { errAsync, ResultAsync } from "neverthrow"; type FetchError = NetworkError | HttpError | ParseError; interface NetworkError { type: "network"; error: Error; } interface HttpError { type: "http"; status: number; headers: Headers; json?: E; } interface ParseError { type: "parse"; error: Error; } export function safeFetch( input: URL | string, init?: RequestInit ): ResultAsync> { // 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, (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 ```ts 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 ```ts const result = (id: string) => safeFetch(`/users/${id}`) .andThen(safeZodParse(userSchema)) .orTee(logError) .andTee(queueEmailNotification) ``` Here `result` remains `Result`. 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? ```ts 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!