24.6 Discriminated Union Error Models
Right, let’s talk about error handling that doesn’t suck. You’ve probably been told a thousand times to “throw errors” and “catch” them. In the small, that’s fine. But at the application level, treating errors as a shocking, exceptional surprise is like being surprised by rain in London. It’s going to happen. The question is, are you prepared with an umbrella, or are you just going to get wet and complain?
The old-school way—throwing strings or simple Error objects—is a one-way ticket to pain. You catch something and then what? You’re left playing a guessing game. if (error.message === 'something vague')? No thank you. We need structure. We need type safety. We need to know exactly what went wrong so we can handle it appropriately, not just panic and log it.
This is where Discriminated Unions waltz in, looking brilliant. The core idea is stupidly simple, yet transformative: Your function doesn’t just return a value or throw an error. It always returns a single, structured object that explicitly tells you whether it succeeded or failed. The magic is that TypeScript’s type system can understand this structure and force you to handle every possibility. It’s like having a very smart, very pedantic friend who won’t let you forget your keys.
The Basic Blueprint: Result Type
We start by defining a type that can represent either a success or a failure. We typically call this a Result or Either type (if you’re into functional programming lingo). Here’s the standard issue, works-every-time model:
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
This is a discriminated union. The discriminant is the success property. It’s a literal boolean that lets TypeScript (and you) know which branch of the union you’re dealing with. T is your happy path data type, and E is your error type, which defaults to the standard Error but can be anything you want—a string, a number, a custom error object, you name it.
A Concrete Example: Fetching User Data
Let’s move from theory to practice. Imagine a function that fetches a user. Here’s how we’d wield our new Result type.
// First, let's define a more specific error type than just 'Error'.
// This is a crucial step! It tells us WHAT went wrong.
type UserError =
| { type: 'USER_NOT_FOUND'; userId: string }
| { type: 'NETWORK_FAILURE'; code: number }
| { type: 'INVALID_INPUT'; reason: string };
async function getUser(id: string): Promise<Result<User, UserError>> {
if (!id) {
// Instead of throwing, we return a structured failure.
return {
success: false,
error: { type: 'INVALID_INPUT', reason: 'ID cannot be empty' },
};
}
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
// Handle different HTTP status codes gracefully
if (response.status === 404) {
return {
success: false,
error: { type: 'USER_NOT_FOUND', userId: id },
};
}
return {
success: false,
error: { type: 'NETWORK_FAILURE', code: response.status },
};
}
const user: User = await response.json();
return { success: true, data: user }; // Happy path!
} catch (err) {
// This catches actual network errors (e.g., no internet)
return {
success: false,
error: { type: 'NETWORK_FAILURE', code: 0 },
};
}
}
See what we did there? The function never throws. It always resolves its Promise with a clear, predictable structure. Now, let’s see the beauty of consuming this function.
The Payoff: Handling Errors Without try/catch
This is where the pattern sings. You call the function and are immediately forced by the type system to check the discriminant.
const result = await getUser('123');
// TypeScript knows 'result' is a union. It won't let you access 'data' or 'error' blindly.
if (result.success) {
// TypeScript narrows the type to { success: true; data: User }
console.log(`Hello, ${result.data.name}`);
// You can use the data with complete confidence here.
} else {
// TypeScript narrows the type to { success: false; error: UserError }
switch (result.error.type) {
case 'USER_NOT_FOUND':
console.error(`User ${result.error.userId} is missing!`);
break;
case 'NETWORK_FAILURE':
console.error(`Network hiccup. Code: ${result.error.code}`);
break;
case 'INVALID_INPUT':
console.error(`You typed that wrong: ${result.error.reason}`);
break;
}
// No default case needed! TypeScript will ensure you've handled all known variants.
}
This is lightyears ahead of a try/catch block. You get:
- Exhaustiveness: The compiler checks that you’ve handled every possible error case you defined.
- Type Safety: No more guessing the type of
error. Each error branch has its own specific, typed properties. - No Accidental Success Path Errors: You literally cannot access
result.datauntil you’ve proven it exists.
Leveling Up: The neverthrow Library
Now, a confession. Writing all this boilerplate yourself gets old. You end up rewriting helper functions for mapping, chaining, and unwrapping results. This is one of those moments where the open-source ecosystem has your back. The neverthrow library does all the heavy lifting for you, providing a rich, functional API for this exact pattern.
import { Result, ok, err } from 'neverthrow';
// Using neverthrow's Result type
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return err('Division by zero'); // equivalent to { success: false, error: ... }
}
return ok(a / b); // equivalent to { success: true, data: ... }
}
// Now you can use powerful methods like `andThen` (flatMap) for chaining:
const result = ok(10)
.andThen((value) => divide(value, 2)) // results in ok(5)
.andThen((value) => divide(value, 0)); // results in err('Division by zero')
// And it remains type-safe through the entire chain.
I highly recommend using a library like this for serious work. It formalizes the pattern and saves you from reinventing a slightly wobbly wheel.
The shift to discriminated unions for errors is less of a new trick and more of a fundamental change in philosophy. You stop treating errors as exceptions to your flow and start treating them as data—first-class citizens in your program’s logic. It makes your code more robust, more explicit, and infinitely easier to reason about. And honestly, once you get used to it, you’ll wonder how you ever lived without it.