Introduction
You deploy a feature that retrieves a user's account balance, but a caller passes invalid input and your code throws an uncaught exception in production. The Result pattern: a Design Patterns guide for developers is a straightforward way to avoid these scenarios. By making failure paths explicit in your return types, you shift from exceptions as a default control flow mechanism to a system where every caller must consciously decide how to handle both success and failure.
This article explores why exceptions hide complexity, how Result types make failure handling mandatory, and when to reach for each approach in your codebase.
Exceptions hide failure paths
When you throw an exception, the caller needs to know it's possible in the first place. Consider a function that fetches a user by ID:
function getUserById(id: string): User {
const record = database.query(`SELECT * FROM users WHERE id = ?`, [id]);
if (!record) {
throw new Error('User not found');
}
return record;
}
// Somewhere in your code
const user = getUserById(customerId);
console.log(user.email);
The function signature suggests it always returns a User. There's no signal that a throw might happen. If you're new to this codebase or reading the code quickly, you might assume this function always succeeds. Then at runtime, an exception bubbles up, your request fails, and your error tracking system lights up.
Exceptions are invisible to the type system in most languages. TypeScript, Java, Go, and others won't tell you that getUserById can fail. You rely on documentation, reading the implementation, or painful trial and error. Even if you try to be defensive with try-catch blocks, you're forced to catch broad categories:
try {
const user = getUserById(customerId);
console.log(user.email);
} catch (e) {
// Did it fail because the user wasn't found, or because the database exploded?
// Did something else throw an exception we weren't expecting?
console.error('Something went wrong:', e);
}
You catch everything, which masks bugs. A typo in your code becomes an "unknown error" instead of surfacing as a real problem. This is where the Result pattern improves clarity and forces you to think through failure modes upfront.
Results are explicit
A Result type encodes success or failure directly in the return value. The simplest form is a discriminated union:
type Result =
| { ok: true; value: T }
| { ok: false; error: E };
Now rewrite getUserById:
function getUserById(id: string): Result {
const record = database.query(`SELECT * FROM users WHERE id = ?`, [id]);
if (!record) {
return { ok: false, error: 'User not found' };
}
return { ok: true, value: record };
}
// The caller sees it in the type signature
const result = getUserById(customerId);
if (result.ok) {
console.log(result.value.email);
} else {
console.error('Failed:', result.error);
}
The return type tells the whole story. A reader immediately knows the function might fail and that there are two branches to handle. TypeScript's type system enforces it—if you forget the if (result.ok) check and try to access result.value, the compiler complains.
You can also use a typed error instead of a string:
type UserNotFoundError = {
type: 'NOT_FOUND';
userId: string;
};
type DatabaseError = {
type: 'DB_ERROR';
message: string;
};
type GetUserError = UserNotFoundError | DatabaseError;
function getUserById(id: string): Result {
const record = database.query(`SELECT * FROM users WHERE id = ?`, [id]);
if (!record) {
return { ok: false, error: { type: 'NOT_FOUND', userId: id } };
}
return { ok: true, value: record };
}
// Now you can handle specific errors
const result = getUserById(customerId);
if (result.ok) {
console.log(result.value.email);
} else if (result.error.type === 'NOT_FOUND') {
console.warn(`User ${result.error.userId} does not exist`);
} else {
console.error('Database error:', result.error.message);
}
Typed errors make your code self-documenting. Future maintainers (including yourself in three months) won't need to guess what errors are possible—they're right there in the signature.
Use for expected failures
The Result pattern shines for failures you anticipate and can recover from. Validation is the textbook case:
type ValidationError = {
field: string;
message: string;
};
function parseAmount(s: string): Result {
const n = Number(s);
if (isNaN(n)) {
return { ok: false, error: { field: 'amount', message: 'Invalid number' } };
}
if (n < 0) {
return { ok: false, error: { field: 'amount', message: 'Must be non-negative' } };
}
return { ok: true, value: n };
}
// Easy to compose validation
function validateTransfer(to: string, amount: string): Result<{ to: string; amount: number }, ValidationError> {
const amountResult = parseAmount(amount);
if (!amountResult.ok) {
return amountResult;
}
if (to.length === 0) {
return { ok: false, error: { field: 'to', message: 'Recipient required' } };
}
return { ok: true, value: { to, amount: amountResult.value } };
}
Use Results for "not found" queries, conflicts, and authorization failures—anything that represents a legitimate application state that the caller needs to handle:
type FindResult = Result;
function findOrderById(orderId: string): FindResult {
// ...
}
type UpdateResult = Result;
function updateOrderStatus(orderId: string, newStatus: string): UpdateResult {
// ...
}
These patterns are part of your normal business logic. A user clicks a button, an order isn't found, your API returns a 404. The caller expected this as a possibility.
Keep exceptions for bugs
Exceptions still have a place. Use them for truly unexpected failures—bugs, environmental problems, or invariant violations that should never happen:
function parseConfig(json: string): AppConfig {
try {
const data = JSON.parse(json);
if (!data.port) {
throw new Error('Config must have a port');
}
if (data.port < 1 || data.port > 65535) {
throw new Error('Port must be between 1 and 65535');
}
return data;
} catch (e) {
// This is a fatal error—throw it up to crash the app if needed
throw new Error(`Failed to parse config: ${e}`);
}
}
// During startup, before the app is serving requests
const config = parseConfig(process.env.CONFIG_JSON);
// If we reach here, the config is valid
Parse errors, out-of-memory, and null pointer exceptions (which suggest a logic bug in your code, not user input) are exceptions. A malformed JSON config isn't something your handlers should gracefully recover from—it's a deployment mistake.
The distinction is recoverable vs. fatal. Results are for recoverable errors in your business logic; exceptions are for things that shouldn't happen and indicate a deeper problem if they do.
Common mistakes
- Mixing Results and exceptions in the same function: If a function returns a
Result, callers expect to handle both branches. If it also throws, they'll forget the try-catch. Stick to one approach per function. - Using Results for truly unexpected errors: If your code calls
JSON.parseon a file you control, let the exception propagate rather than wrapping it in a Result. You want it to fail visibly so you fix the bug, not hide it behind error handling. - Not providing utilities for chaining: Plain Result types require verbose if-checks. Real codebases benefit from helpers like
map,flatMap, andmatchto reduce nesting and improve readability. - Ignoring the error case: If you have a Result, always handle both branches. Using
result.valuewithout checkingresult.okdefeats the point. - Making error types too generic: A single
stringerror loses information. Use typed errors so callers can act on specifics, not guesses.
Key takeaways
- Exceptions hide failure paths from callers and the type system; Results make them explicit.
- Use Results for expected, recoverable failures: validation, not found, conflicts, permission denied.
- Keep exceptions for unexpected bugs and invariant violations.
- Typed errors make your code self-documenting and enable precise error handling.
- Enforce Result handling in your codebase with linters or code review; don't let Results be silently ignored.






Comments
Loading comments…