24.3 Result Types: Encoding Success and Failure in the Type System
Let’s be honest: you’ve probably handled errors by throwing them. It’s the JavaScript way, and TypeScript inherits it. You call a function, it blows up, you try/catch it. Simple, right? But it’s also incredibly rude. A function that throws is like a guest who, instead of saying “I’m allergic to shellfish,” just vomits on your table. You had no warning, and now you’re left cleaning up the mess.
There’s a more polite, more predictable, and frankly, more type-safe way: the Result type. This isn’t a language feature; it’s a pattern. A design pattern where we encode success and failure into our type system, forcing us (and anyone using our code) to handle both outcomes. The compiler becomes our copilot, making it a compile-time error to forget that things can, and will, go wrong.
The core idea is stupidly simple. Instead of returning a value (T) or throwing an error, a function returns an object that explicitly says which one of those two things happened. It’s a box that contains either a happy path result or a sad path error.
The Basic Building Blocks
We start by defining a type, typically a discriminated union. Here’s the classic formulation:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
This is the bedrock. It’s either one thing or the other. The ok property is the discriminant—a boolean flag the type checker can use to narrow the type. If ok is true, the object must have a value of type T. If ok is false, it must have an error of type E. We default E to the standard Error, but you can use string codes, custom error classes, or even a tuple of [code, message]—whatever gives you the most context.
Now, a function that might fail has a signature that tells the whole story:
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return { ok: false, error: "Cannot divide by zero" };
}
return { ok: true, value: a / b };
}
Look at that! No throw. No hidden control flow. The signature Result<number, string> is a clear contract: “I will return a number on success, or a string explaining why I failed.” The caller must deal with this.
Unwrapping the Result (The Right Way)
So you’ve got this Result object. How do you use it? You check the ok flag. It’s manual, but it’s explicit and safe.
const result = divide(10, 2);
if (result.ok) {
// TypeScript knows 'result' is { ok: true; value: number } here
console.log(`The result is ${result.value}`);
} else {
// TypeScript knows 'result' is { ok: false; error: string } here
console.error(`Oops: ${result.error}`);
}
This is the gold standard. It’s completely type-safe and forces error handling. The alternative is the try/catch wild west, where it’s trivially easy to let an error bubble up to a place where you have no context left to handle it meaningfully.
Leveling Up: The match Method
Checking ok everywhere gets tedious. This is where wrapping the Result in a class with utility methods pays massive dividends. It’s the difference between a bare bones union and a truly ergonomic tool.
class Result<T, E = Error> {
constructor(
public readonly ok: boolean,
public readonly value?: T,
public readonly error?: E
) {}
// A static constructor for success
static success<U, F = Error>(value: U): Result<U, F> {
return new Result<U, F>(true, value, undefined);
}
// A static constructor for failure
static failure<U, F = Error>(error: F): Result<U, F> {
return new Result<U, F>(false, undefined, error);
}
// The killer feature: a pattern-matching method
match<U>(onSuccess: (value: T) => U, onFailure: (error: E) => U): U {
if (this.ok) {
return onSuccess(this.value!); // The '!' is safe because of the 'ok' flag
} else {
return onFailure(this.error!);
}
}
}
// Usage becomes declarative and beautiful
const computation = divide(10, 0); // returns our Class-based Result
const message = computation.match(
(value) => `The answer is ${value}`,
(err) => `The computation failed: ${err}`
);
console.log(message); // "The computation failed: Cannot divide by zero"
The match method is the star of the show. It requires you to provide both a success and a failure handler upfront. There’s no way to forget to handle the error case. It’s all right there, neat and tidy.
Chaining Operations Without the Mess
This is where Result types truly outshine try/catch. Imagine a sequence of operations where any one can fail. With try/catch, you end up with a deeply nested mess or a long chain in a single catch block where you have to figure out what went wrong. With Results, you can chain them elegantly.
// A simple "bind" method for our Result class
chain<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
return this.match(
(value) => fn(value), // If we're successful, run the next function
(err) => Result.failure<U, E>(err) // If we failed, just pass the error along
);
}
Now watch how we compose operations:
function parseNumber(input: string): Result<number, string> {
const num = parseInt(input, 10);
if (isNaN(num)) {
return Result.failure("Not a number");
}
return Result.success(num);
}
function double(n: number): Result<number, string> {
if (n > 1000) {
return Result.failure("Number too large");
}
return Result.success(n * 2);
}
const finalResult = parseNumber("42")
.chain(double)
.chain(double);
// finalResult will be:
// { ok: true, value: 168 } if everything worked.
// { ok: false, error: "Not a number" } if parseNumber failed.
// { ok: false, error: "Number too large" } if the first double failed.
The error path is a first-class citizen. The moment any function in the chain returns a failure, the subsequent chain calls just short-circuit and pass that failure along until something handles it. It’s like a railway switch: once you’re on the failure track, you stay on it. This is known as the “Railway Oriented Programming” pattern, and it’s a game-changer for writing robust, composable code.
The Pitfalls and The Pedantry
No pattern is perfect. The obvious drawback is verbosity. You’re trading a throw and a catch for more explicit code. I argue this is a feature, not a bug. You’re making the hidden visible.
The other gotcha is interop with existing code that does throw. You’ll need to wrap it. I call this “containing the explosion.”
function tryCatchToResult<U, F = Error>(
fn: () => U,
errorMapper: (thrown: unknown) => F = (e) => e as F
): Result<U, F> {
try {
return Result.success(fn());
} catch (e) {
return Result.failure(errorMapper(e));
}
}
// Wrap a filthy throwing function
const riskyData = tryCatchToResult(() => JSON.parse(userInput));
You should also be judicious about what you use for your error type E. A simple string is easy but can be limiting. A custom class with a code, message, and maybe even a cause property gives you much more to work with when handling errors upstream.
The Result pattern asks more of you upfront. It requires discipline. But the payoff is immense: functions you can truly trust, with failures that are type-checked and impossible to ignore. Your code becomes less of a rickety tower of assumptions and more of a predictable, manageable data flow. And that’s worth the extra keystrokes.