24.4 neverthrow and Other Result Libraries
Right, let’s talk about error handling without throwing your toys out of the pram. You’ve probably been told a thousand times, “Don’t use exceptions for control flow!” It’s good advice. Exceptions are like shouting “FIRE!” in a crowded theatre—effective, but disruptive, and you can’t be sure who’s going to handle the panic. In TypeScript, they also completely bypass your type system. A function that throws lies about its return type; it says it returns a string but might instead yeet a WrenchError into your runtime.
This is where the Result pattern waltzes in, looking smug and composed. The core idea is simple but brilliant: a function that can fail doesn’t return the happy-path value or throw an exception. It returns a single object that contains either the successful value or the failure reason. This object forces you to handle both possibilities. Your types tell the truth. The compiler becomes your anxious, helpful friend, constantly asking, “Yes, but what if it went wrong?”
The neverthrow Library: A Prime Example
While you could roll your own Result type (and I’ll show you that in a bit), let’s start with a best-in-class library: neverthrow. It’s a fantastic implementation because it’s ruthlessly simple and typesafe.
First, get it into your project:
npm install neverthrow
Now, let’s use it. Imagine a function that parses a configuration file. It can fail in a few known ways.
import { ok, err, Result } from 'neverthrow';
// First, define your error types. This is crucial! Don't just use `Error` or `string`.
// Be specific so you can handle different failures differently.
class FileReadError extends Error { /* ... */ }
class ParseError extends Error { /* ... */ }
function parseConfig(configPath: string): Result<Config, FileReadError | ParseError> {
// Try to read the file
const fileContents = tryReadFile(configPath);
if (fileContents === null) {
// On failure, return an `err` wrapping a specific error instance
return err(new FileReadError(`Could not read file at ${configPath}`));
}
// Try to parse the contents
try {
const config: Config = JSON.parse(fileContents);
return ok(config); // On success, return an `ok` wrapping the value
} catch (e) {
return err(new ParseError('Invalid JSON in config file', { cause: e }));
}
}
Notice the return type: Result<Config, FileReadError | ParseError>. This is a promise the compiler can enforce. It says, “I will give you an object that contains either a Config or one of these two specific errors.” No surprises.
Unwrapping the Result: The How and Why
The returned Result object is useless until you unwrap it. neverthrow forces you to do this explicitly, which is the entire point. You have two primary methods: .map() for success and .mapErr() for failure. These are for transforming the values inside the result without unwrapping it.
But to actually get the value out and use it, you use .match(). This is pattern matching, and it’s the gold standard.
const configResult = parseConfig('./my-config.json');
// The most explicit, safe, and compiler-verified way to handle it:
configResult.match(
// Handle the happy path. `config` is of type `Config`
(config) => {
console.log('Server starting with port:', config.port);
startServer(config);
},
// Handle the failure. `error` is of type `FileReadError | ParseError`
(error) => {
if (error instanceof FileReadError) {
console.error('Filesystem error:', error.message);
// Maybe try a default config path?
} else {
console.error('Malformed config, you probably have a typo:', error.message);
// Exit gracefully
process.exit(1);
}
}
);
The beauty here is that the compiler will complain if you don’t handle both cases. You’ve moved error handling from a try/catch block, which is often an afterthought, to a first-class citizen in your logic flow.
The Pit of Despair: Async Operations
Where people usually face-plant is with async functions. neverthrow has you covered with ResultAsync. This is a promise that always resolves successfully, but its resolution value is a Result.
import { ResultAsync, okAsync, errAsync } from 'neverthrow';
function fetchUser(userId: string): ResultAsync<User, DatabaseError | NetworkError> {
// We wrap the failing promise. `ResultAsync.fromPromise` takes two arguments:
// 1. The promise itself.
// 2. An error handler that converts the promise's rejection into our specific error type.
return ResultAsync.fromPromise(
dbClient.query('SELECT * FROM users WHERE id = $1', [userId]),
(e) => new DatabaseError('Query failed', e)
);
}
// Usage is the same, but you have to `await` it!
const userResult = await fetchUser('123');
userResult.match(
(user) => sendWelcomeEmail(user.email),
(error) => logErrorToMonitoringService(error)
);
The critical thing to understand is that fetchUser does not return a promise that can reject. It returns a promise that always resolves with a Result object. This flattens your error handling into a single, consistent pattern for both sync and async code. It’s genuinely elegant.
When to Roll Your Own (And When Not To)
You don’t need a library for this. The pattern is simple enough. Here’s a bare-bones, functional version:
type Result<T, E> = { success: true; value: T } | { success: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { success: true, value };
}
function err<E>(error: E): Result<never, E> {
return { success: false, error };
}
function trySomething(): Result<string, Error> {
return Math.random() > 0.5 ? ok('Hello') : err(new Error('Nope!'));
}
So why use neverthrow? Because the devil is in the details. A good library provides:
Promiseintegration (viaResultAsync) without you having to wire it yourself.- Combinator methods like
.andThen()(aka “flatMap”) for chaining operations that can fail. - Utilities like
Result.combineto take a list of results and succeed only if all succeed. - Robust type inference that you will inevitably get wrong in your first five custom implementations.
My advice? Use the library. It’s lightweight and solves the problem completely. Only consider a custom type if you’re on a extreme bundle-size budget and need only the most basic sync functionality. Otherwise, you’re just recreating a worse version of it and introducing potential bugs. Your brilliant friend (me) has already made that mistake so you don’t have to.