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-10·8 min·intermediate
#typescript#error-handling#patterns
613 words · 197 lines
Xonoxc

Problem

For years I wrote code like this:

code

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:

code
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

Implementation

The Result type forces errors into the type system:

code
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.

Third-party Integration

Most libraries throw. Wrapping every call in try-catch is tedious.

Tradeoffs

Tradeoffs.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

Improvements

  • Pattern matching in TypeScript (when TC39 proposal lands)
  • Effectfully-typed pipelines
  • Automatic error type narrowing in switch statements