The Result Pattern: Why I Stopped Throwing Exceptions
In this piece
The Result Pattern: Why I Stopped Throwing Exceptions
The Exception Trap
Every production Node app has seen an Unhandled Exception crash the process. If exceptions are so great, why does every function have an invisible contract that says “I might throw, good luck figuring out what”?
I used to think exceptions were the only sane way to handle errors in TypeScript. Then I started writing tests first, and every error path became a toThrow() guess.
In TDD Isn’t Just for Catching Bugs, I introduced the Result<T, E> type as a testing convenience. It made error paths explicit in tests. The more I used it, the more I realized: Result types are a better way to handle errors.
We’re going beyond the basics: combinators, composition, migration strategies, and a decision framework for when to use Results, Options, and exceptions.
Why Exceptions Fail
function getUser(id: string): User {
const user = db.findUser(id);
if (!user) throw new NotFoundError("User not found");
if (!user.isActive()) throw new InactiveUserError(user);
return user;
}Read that signature: it promises a User. It can also throw, and the type system doesn’t tell you. You can’t know what might go wrong without reading the function and everything it calls.
try {
const user = getUser("123");
renderProfile(user);
} catch (e) {
if (e instanceof NotFoundError) return render404();
if (e instanceof InactiveUserError) return renderInactive();
// DatabaseError? NetworkError? The signature won't tell you.
}What’s wrong:
- Hidden control flow. Any function can throw anything. The signature won’t tell you.
- No composability. You can’t chain
getUserwithgetOrdersbecause each might throw and you need separate try/catch blocks. - Testing is awkward.
expect(() => fn()).toThrow()tells you something was thrown, not what the error path produces. - Error swallowing. A broad
catchthat logs and moves on suppresses errors you didn’t anticipate.
I lived with this for years.
The Result Type, Fully Explained
In the permission-slip article, I showed a simple Result<T, E> type. Let’s build the complete version: the one I use in production.
type Result<T, E> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}Two shapes: success carries a value, failure carries an error. The ok field is the discriminator: TypeScript narrows the type automatically when you check it.
Combinators work with Results without unpacking them:
function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
if (!result.ok) return result;
return ok(fn(result.value));
}
function flatMap<T, U, E, F>(
result: Result<T, E>,
fn: (value: T) => Result<U, F>
): Result<U, E | F> {
if (!result.ok) return result;
return fn(result.value);
}
function match<T, U, E>(
result: Result<T, E>,
onSuccess: (value: T) => U,
onError: (error: E) => U
): U {
if (result.ok) return onSuccess(result.value);
return onError(result.error);
}map transforms the success value, passing errors through untouched. flatMap chains operations that also return Results, accumulating their error types into a union (E | F). If any step fails, the whole chain short-circuits. match forces you to handle both cases.
Let’s rewrite the getUser function:
function getUser(id: string): Result<User, NotFoundError | InactiveUserError> {
const user = db.findUser(id);
if (!user) return err(new NotFoundError("User not found"));
if (!user.isActive()) return err(new InactiveUserError(user));
return ok(user);
}Look at the return type: Result<User, NotFoundError | InactiveUserError>. You can see what can go wrong without reading the implementation or hoping the docs are current.
const result = getUser("123");
match(
result,
(user) => renderProfile(user),
(error) => {
if (error instanceof NotFoundError) return render404();
if (error instanceof InactiveUserError) return renderInactive();
}
);Every failure goes through the handler; match won’t typecheck without one.
TDD With Result Types
In the first article, I showed how Result<T, E> makes tests explicit.
class InvalidEmailError {
constructor(public readonly input: string) {}
get message(): string {
return `Invalid email: ${this.input}`;
}
}
function validateEmail(email: string): Result<string, InvalidEmailError> {
const normalized = email.trim().toLowerCase();
if (!normalized.includes("@")) {
return err(new InvalidEmailError(normalized));
}
return ok(normalized);
}Testing exceptions means asserting on control flow:
function validateEmailOrThrow(email: string): string {
const normalized = email.trim().toLowerCase();
if (!normalized.includes("@")) {
throw new InvalidEmailError(normalized);
}
return normalized;
}
it("should throw for invalid email", () => {
expect(() => validateEmailOrThrow("bad")).toThrow("Invalid email");
});Testing Results:
it("should return error for invalid email", () => {
const result = validateEmail("bad");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(InvalidEmailError);
expect(result.error.input).toBe("bad");
}
});
it("should return normalized valid email", () => {
const result = validateEmail(" [email protected] ");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toBe("[email protected]");
}
});With Results, I can assert on the error itself: its type, its properties, its message. toThrow() gives me a string match; the error’s properties stay untested.
Every factory method in my Value Objects and every mutation in my Aggregates returns a Result. My whole domain is explicit about what can fail.
And when I use fakes instead of mocks, every fake repository returns Result too, so my tests exercise the same error paths production code will follow.
Composing Pipelines With flatMap
This is the part that sold me. Composing multi-step operations with exceptions means nested try/catch. With Results and flatMap, the first failure returns immediately, no per-step catch blocks.
class CardValidationError {
constructor(public readonly reason: string) {}
}
class InsufficientFundsError {
constructor(
public readonly requested: number,
public readonly available: number
) {}
}
class PaymentProcessingError {
constructor(public readonly code: string) {}
}
class EmailDeliveryError {
constructor(public readonly reason: string) {}
}
type PaymentError =
| CardValidationError
| InsufficientFundsError
| PaymentProcessingError
| EmailDeliveryError;
function validateCard(cardNumber: string): Result<string, CardValidationError> {
if (cardNumber.length !== 16) {
return err(new CardValidationError("Card number must be 16 digits"));
}
return ok(cardNumber);
}
function checkBalance(
amount: number,
available: number
): Result<number, InsufficientFundsError> {
if (amount > available) {
return err(new InsufficientFundsError(amount, available));
}
return ok(amount);
}
function chargeCard(
card: string,
amount: number
): Result<string, PaymentProcessingError> {
if (amount <= 0) {
return err(new PaymentProcessingError("INVALID_AMOUNT"));
}
return ok(`receipt_${Date.now()}`);
}
function sendConfirmation(
email: string,
receiptId: string
): Result<void, EmailDeliveryError> {
if (!email.includes("@")) {
return err(new EmailDeliveryError("Invalid recipient"));
}
return ok(undefined);
}The pipeline has four steps, each can fail. flatMap composes them:
function processPayment(
cardNumber: string,
amount: number,
availableBalance: number,
customerEmail: string
): Result<string, PaymentError> {
return flatMap(validateCard(cardNumber), (card) =>
flatMap(checkBalance(amount, availableBalance), (validatedAmount) =>
flatMap(chargeCard(card, validatedAmount), (receiptId) =>
map(sendConfirmation(customerEmail, receiptId), () => receiptId)
)
)
);
}If validateCard fails, the pipeline short-circuits and returns that error. Same for the other steps: the first failure stops the chain.
The caller handles it:
const result = processPayment("1234567890123456", 50, 100, "[email protected]");
match(
result,
(receiptId) => console.log(`Payment successful: ${receiptId}`),
(error) => {
if (error instanceof CardValidationError) {
console.log(`Card error: ${error.reason}`);
} else if (error instanceof InsufficientFundsError) {
console.log(`Need ${error.requested}, have ${error.available}`);
} else if (error instanceof PaymentProcessingError) {
console.log(`Payment failed: ${error.code}`);
} else if (error instanceof EmailDeliveryError) {
console.log(`Confirmation failed: ${error.reason}`);
}
}
);Every error is visible in the signature, and the caller handles them all in one place.
Each step is a pure function, so testing the pipeline needs no scaffolding:
it("should reject expired card", () => {
const result = processPayment("0000", 50, 100, "[email protected]");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(CardValidationError);
}
});
it("should reject insufficient funds", () => {
const result = processPayment(
"1234567890123456",
200,
100,
"[email protected]"
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(InsufficientFundsError);
}
});
it("should process valid payment", () => {
const result = processPayment(
"1234567890123456",
50,
100,
"[email protected]"
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toMatch(/^receipt_/);
}
});Each test inspects the returned value directly: six to eight lines, no mocks.
Migration Strategy: From Exceptions to Results
This is the gradual path I’ve used on three production codebases.
Start With New Code
New functions return Result. Old functions stay as they are. The two patterns coexist:
function validateAmount(amount: number): Result<number, string> {
if (amount <= 0) return err("Amount must be positive");
return ok(amount);
}Wrap Libraries at the Boundary
Third-party libraries throw. Wrap them:
function safeJsonParse<T>(input: string): Result<T, SyntaxError> {
try {
return ok(JSON.parse(input));
} catch (e) {
if (e instanceof SyntaxError) return err(e);
return err(new SyntaxError("Unknown JSON parse error"));
}
}
function safeFileRead(path: string): Result<string, NodeJS.ErrnoException> {
try {
return ok(fs.readFileSync(path, "utf-8"));
} catch (e) {
if (e instanceof Error) return err(e as NodeJS.ErrnoException);
return err(new Error(String(e)) as NodeJS.ErrnoException);
}
}The adapter converts exceptions to Results at the boundary. Your domain code never has to catch anything.
Convert Existing Functions One at a Time
Pick a function. Change its return type to Result. Fix the callers. Run tests. Repeat:
// Before
function calculateTax(amount: number, rate: number): number {
if (rate < 0 || rate > 1) throw new Error("Invalid tax rate");
return Math.round(amount * rate * 100) / 100;
}
// After
function calculateTax(amount: number, rate: number): Result<number, string> {
if (rate < 0 || rate > 1) return err("Invalid tax rate");
return ok(Math.round(amount * rate * 100) / 100);
}This is the kind of refactoring TDD gives you permission to do. The behavior tests still pin the expected outputs, so updating the call sites is mechanical.
Libraries vs. Rolling Your Own
For production code, neverthrow and ts-results provide Result types with all the combinators above.
I built my own for this series because I wanted simplicity and full control. My implementation is 20 lines. For many codebases, that’s enough. Errors as values doesn’t require a library.
Result vs Option vs Exception: The Decision Framework
Not everything should be a Result. The decision tree I use:
Can the operation fail?
├── No → Return T directly
└── Yes → Can it fail with meaningful information?
├── No → Return Option<T> (the absence is the information)
└── Yes → Return Result<T, E>Return T directly when failure is impossible:
function formatCurrency(amount: number): string {
return `$${amount.toFixed(2)}`;
}Return Option<T> (or T | null) when the failure mode is “not found” and the caller doesn’t need to know why:
function findUserByEmail(email: string): User | null {
return users.get(email) ?? null;
}Return Result<T, E> when failure carries information the caller needs:
function validateEmail(email: string): Result<Email, InvalidEmailError> {
if (!email.includes("@")) return err(new InvalidEmailError(email));
return ok(new Email(email));
}Reserve exceptions for failures the caller can’t handle:
- Out of memory
- Stack overflow
- Programming errors (assertion failures, impossible states)
- Infrastructure catastrophes (database connection dropped mid-query)
Not for expected business outcomes. “Invalid email”, “user not found”, “insufficient funds”: these are expected outcomes your domain should model explicitly.
FAQ
Should I use Result types instead of exceptions everywhere?
No. Use Results for business logic errors: validation failures, “not found” cases, permission denials, state violations. Keep exceptions for the exceptional: programming errors, infrastructure failures, out-of-memory conditions. A case your domain model handles is a Result; a case your process crashes on is an exception.
What is the Result monad in TypeScript?
A Result type is a discriminated union representing success ({ ok: true, value: T }) or failure ({ ok: false, error: E }). The “monad” part refers to its composability: you can chain operations with flatMap, transform values with map, and handle both cases with match, without unpacking the Result manually. In TypeScript you use it the same way in practice; it isn’t a formal monad because the language has no typeclasses.
How do you chain operations with Result types?
Use flatMap. Each step returns a Result, and flatMap short-circuits on the first failure:
const result = flatMap(validateEmail(input), (email) =>
flatMap(createUser(email), (user) => sendWelcomeEmail(user))
);If validateEmail fails, createUser and sendWelcomeEmail never run. No try/catch, no nesting, no forgotten error case.
What Changed for Me
After a year of Result types in my domain code:
- Signatures became documentation. I stopped reading function bodies to learn what could fail. The return type lists it.
- Changing an error type breaks every unhandled call site at compile time. The compiler finds the gaps I would have missed.
- Tests describe outcomes. Asserting on error values beats
toThrow()guesses. - Code review got shorter. “What happens when this fails?” stopped being my most common review comment. The answer is in the type.
Exceptions still have their place: process-level failures, bugs, infrastructure. Business errors are values now.
The Bigger Picture
Result types aren’t a TypeScript invention. Haskell has Either, Rust has Result, OCaml and F# model errors the same way. The mainstream is catching up: C# is moving toward discriminated unions, and Java keeps absorbing Scala’s playbook: sealed types, records, pattern matching. It’s a shame the pattern still isn’t the default after functional languages proved it out decades ago.
TypeScript doesn’t have it built in either. Fortunately, Effect makes it a standard: Effect, Either, and Option with the full combinator set, the same role Arrow plays for Kotlin. If my 20-line version ever feels thin, that’s where I’d go next.
Filed under
Next in TDD & Software Design
TDD in the Age of AI: Who Tests the Tests?
Part 8 continues the series.