Exceptions vs. Error Codes
I once spent two hours debugging a production incident because someone returned -1 from a function and the caller never checked it. The invalid value propagated through three layers of code before surfacing as "undefined is not a function" in a completely unrelated module.
Error codes are easy to ignore. Exceptions force you to deal with failure—or crash loudly.
The choice between exceptions, error codes, and Result types isn't about right or wrong. It's about which failure model fits your context.
A Brief History
Error Codes: The C Era
In C, there's no exception mechanism. Functions return integers: 0 for success, non-zero for failure. The caller must check every return value.
int result = open_file("config.json");
if (result != 0) {
// Handle error
}
The problem: nothing enforces the check. Developers forget. The code compiles. The bug ships.
Exceptions: The Java/C# Era
Exceptions emerged to make failures impossible to ignore. A thrown exception propagates up the call stack until caught. Uncaught exceptions crash the program—which is often better than silently corrupting data.
function loadConfig(path) {
const content = readFileSync(path);
return JSON.parse(content);
}
// If readFileSync or JSON.parse throws, caller must catch or crash
The trade-off: exceptions are invisible in the type signature. You can't tell from loadConfig(path) that it might throw. Callers discover this at runtime—or in production.
Result Types: The Functional Era
Languages like Rust and Elm use Result (or Either) types. The return type encodes the possibility of failure:
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
function loadConfig(path: string): Result<Config, LoadError> {
try {
const content = readFileSync(path);
return { ok: true, value: JSON.parse(content) };
} catch (e) {
return { ok: false, error: new LoadError(path, e) };
}
}
The caller must handle both cases. The type system enforces it. No silent failures.
When to Use What
Use Exceptions For: Unexpected Failures
Things that shouldn't happen in normal operation. Out-of-memory. Database connection lost mid-query. Null reference when an invariant was violated.
function processOrder(order: Order): void {
if (!order.id) {
throw new Error('Order must have id—invariant violated');
}
// This is a programming error, not a business case
}
Use Return Values For: Expected Alternatives
Things that are part of the domain. User not found. Validation failed. Payment declined.
// Option A: Return null/undefined for "not found"
function findUser(id: string): User | null {
return users.get(id) ?? null;
}
// Option B: Return a Result type
function findUser(id: string): Result<User, NotFoundError> {
const user = users.get(id);
return user ? { ok: true, value: user } : { ok: false, error: new NotFoundError(id) };
}
Both are valid. Choose one and be consistent across your codebase.
Use Error Codes For: Performance-Critical or C Interop
When you're in a hot loop or calling C libraries, exceptions have overhead. Error codes can be faster. Most application code isn't in that category.
Exception Anti-Patterns
Catching Everything
// BAD: Swallows all errors
try {
await processPayment(order);
} catch (e) {
console.log('Something went wrong');
}
You've hidden the failure. The payment might have failed. The order might be in an inconsistent state. You'll never know.
Catching and Re-throwing Without Context
// BAD: Loses the original error
try {
await saveToDatabase(record);
} catch (e) {
throw new Error('Save failed');
}
The original stack trace is gone. The cause is lost. Use cause to chain:
// GOOD: Preserves context
try {
await saveToDatabase(record);
} catch (e) {
throw new Error('Failed to save record', { cause: e });
}
Catching Specific Errors You Can't Handle
// BAD: Catches network error but can't retry or recover
try {
const data = await fetchFromAPI();
return data;
} catch (e) {
if (e instanceof NetworkError) {
return getCachedData(); // Fine: we have a recovery path
}
throw e; // Re-throw what we can't handle
}
Only catch when you have a recovery strategy. Otherwise, let it propagate.
Type-Safe Error Handling in TypeScript
TypeScript doesn't have built-in Result types, but you can model them:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return { ok: false, error: 'Division by zero' };
}
return { ok: true, value: a / b };
}
// Caller must handle both branches
const result = divide(10, 0);
if (result.ok) {
console.log(result.value);
} else {
console.error(result.error);
}
Transforming Results
function mapResult<T, U, E>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> {
return result.ok
? { ok: true, value: fn(result.value) }
: result;
}
function flatMapResult<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
// Chaining operations
const step1 = divide(10, 2);
const step2 = step1.ok ? mapResult(step1, x => x * 2) : step1;
const step3 = step2.ok ? flatMapResult(step2, x => divide(x, 3)) : step2;
Wrapping Exceptions in Results
When calling code that throws, convert at the boundary:
function safeJsonParse<T>(json: string): Result<T, SyntaxError> {
try {
return { ok: true, value: JSON.parse(json) as T };
} catch (e) {
return {
ok: false,
error: e instanceof SyntaxError ? e : new SyntaxError(String(e)),
};
}
}
Consistency Over Ideology
Some teams prefer exceptions everywhere. Others prefer Result types. Both work.
The mistake is mixing them inconsistently: throwing in one module, returning null in another, using Result in a third. Pick a strategy per layer (e.g., Result in domain logic, exceptions at infrastructure boundaries) and document it.
Key insight: Exceptions can't be ignored—they propagate or crash. Error codes and null returns are easy to forget. Result types make failure explicit in the type system. Use exceptions for unexpected failures, return values for expected alternatives, and be consistent. Avoid catching without handling, and always preserve error context when re-throwing.