From Throwing Errors to Result-Based Error Handling
Why I moved from try-catch chaos to typed Result types and how it changed my approach to error recovery.
Problem
For years I wrote code like this:
async function getUser(id: string) {
const user = await db.users.findUnique({ where: { id } })
if (!user) throw new NotFoundError("User not found")
return user
}
Then every caller needed try-catch:
try {
const user = await getUser("123")
renderUser(user)
} catch (error) {
if (error instanceof NotFoundError) {
renderNotFound()
} else if (error instanceof DatabaseError) {
renderError()
}
}
This works until you forget a catch. Or the error type changes. Or you need to handle errors at multiple levels.
Initial Assumptions
I believed exceptions were the "standard" way to handle errors in most languages.
The real issue is that exceptions create invisible control flow. A function's signature lies about what it can actually do. It says "returns User" but can also throw NotFoundError, DatabaseError, NetworkError, and anything else that bubbles up from dependencies.
Implementation
The Result type forces errors into the type system:
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E }
async function getUser(
id: string
): Promise<Result<User, NotFoundError | DatabaseError>> {
try {
const user = await db.users.findUnique({ where: { id } })
if (!user) return { success: false, error: new NotFoundError() }
return { success: true, data: user }
} catch (error) {
return { success: false, error: new DatabaseError(error) }
}
}
// Caller must handle both cases:
const result = await getUser("123")
if (!result.success) {
switch (result.error.constructor) {
case NotFoundError: renderNotFound(); break
case DatabaseError: renderError(); break
}
return
}
renderUser(result.data)
The compiler enforces that every possible path is handled.
Problems Encountered
Verbosity
The biggest complaint is boilerplate. Every call site needs a check.
The solution is a pipe helper that handles the common case while preserving the error path:
function map<T, U, E>(
result: Result<T, E>,
fn: (data: T) => U
): Result<U, E> {
if (!result.success) return result
return { success: true, data: fn(result.data) }
}
This lets you chain operations without losing error context.
Third-party Integration
Most libraries throw. Wrapping every call in try-catch is tedious.
I initially tried to make external APIs return Result types by wrapping at the call site. This scattered error boundaries everywhere.
The better approach is to create an adapter layer that catches at the boundary and converts to your error type:
class ExternalApiAdapter {
async fetch(): Promise<Result<Data, ApiError>> {
try {
return { success: true, data: await rawApi.fetch() }
} catch (e) {
return { success: false, error: normalizeError(e) }
}
}
}
Tradeoffs
| Aspect | Exceptions | Result Types |
|---|---|---|
| Type safety | False — errors are invisible | True — errors in signatures |
| Caller discipline | Optional | Enforced |
| Error propagation | Automatic (stack unwinding) | Explicit (must return) |
| Error recovery | At any level | At any level |
| Third-party compat | High | Requires wrapping |
| Code verbosity | Low | Medium |
| Async complexity | Low | Medium |
Final Mental Model
Exceptions model control flow interrupts. Result types model data with possible failure.
The distinction matters: if an operation can predictably fail (validation, lookup, network), it should return a Result. If it fails due to a programming error (null pointer, invariant violation), an exception is appropriate.
I now follow: "throw for bugs, return for failures."
Improvements
- Pattern matching in TypeScript (when TC39 proposal lands)
- Effectfully-typed pipelines
- Automatic error type narrowing in switch statements
