Back
Backend

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.

2026-06-108 minintermediate
#typescript#error-handling#patterns
EST CONFIGNo response
607 words · 161 lines
Xonoxc

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

[!] Pitfall

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.

> Key Insight

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.

[!] Pitfall

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

[ Tradedoffs.md ]
AspectExceptionsResult Types
Type safetyFalse — errors are invisibleTrue — errors in signatures
Caller disciplineOptionalEnforced
Error propagationAutomatic (stack unwinding)Explicit (must return)
Error recoveryAt any levelAt any level
Third-party compatHighRequires wrapping
Code verbosityLowMedium
Async complexityLowMedium

Final Mental Model

> Key Insight

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