Skip to main content
Software Engineering

Error Handling Patterns in TypeScript Applications

Move beyond try-catch: Result types, error boundaries, and structured error handling that makes your TypeScript apps more resilient.

Mbeah Essilfie

Mbeah Essilfie

June 3, 2026 at 06:03 AM

9 min read 518 views
Error Handling Patterns in TypeScript Applications

The Problem with Try-Catch

Try-catch is invisible in the type system. There's no way to know what errors a function might throw:

// What errors can this throw? No way to know from the signature.
async function getUser(id: string): Promise<User> {
  const user = await db.user.findUnique({ where: { id } })
  if (!user) throw new Error('Not found')
  return user
}

The Result Pattern

Encode success and failure in the return type:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E }

function ok<T>(value: T): Result<T, never> {
  return { ok: true, value }
}

function err<E>(error: E): Result<never, E> {
  return { ok: false, error }
}

Now errors are visible in the type system:

type UserError = 'NOT_FOUND' | 'PERMISSION_DENIED'

async function getUser(id: string): Promise<Result<User, UserError>> {
  const user = await db.user.findUnique({ where: { id } })
  if (!user) return err('NOT_FOUND')
  return ok(user)
}

// Caller MUST handle both cases
const result = await getUser('123')
if (!result.ok) {
  // result.error is typed as UserError
  switch (result.error) {
    case 'NOT_FOUND': return notFound()
    case 'PERMISSION_DENIED': return forbidden()
  }
}
// result.value is typed as User
console.log(result.value.name)
The Result pattern forces callers to handle errors. No more uncaught exceptions crashing your app at 3am.

Custom Error Classes

class AppError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number = 500,
    public details?: unknown,
  ) {
    super(message)
    this.name = 'AppError'
  }

  static notFound(resource: string) {
    return new AppError(`${resource} not found`, 'NOT_FOUND', 404)
  }

  static validation(details: Record<string, string>) {
    return new AppError('Validation failed', 'VALIDATION', 422, details)
  }

  static unauthorized() {
    return new AppError('Unauthorized', 'UNAUTHORIZED', 401)
  }
}

Global Error Handler (Nuxt)

// server/middleware/error-handler.ts
export default defineEventHandler((event) => {
  event.node.res.on('error', (error) => {
    if (error instanceof AppError) {
      sendError(event, createError({
        statusCode: error.statusCode,
        message: error.message,
        data: { code: error.code, details: error.details },
      }))
    }
  })
})

Wrapping External Libraries

// Wrap fetch to return Results
async function safeFetch<T>(url: string): Promise<Result<T, FetchError>> {
  try {
    const response = await fetch(url)
    if (!response.ok) {
      return err({ type: 'HTTP_ERROR', status: response.status })
    }
    const data = await response.json()
    return ok(data as T)
  } catch (e) {
    return err({ type: 'NETWORK_ERROR', message: (e as Error).message })
  }
}

Summary

  • Use Result types for expected failures (validation, not found)
  • Use exceptions for unexpected failures (DB connection lost)
  • Use custom error classes for structured error information
  • Handle errors at the boundary — don't let them propagate silently
TypeScriptNode.jsArchitecture
Mbeah Essilfie

Written by Mbeah Essilfie

Fullstack Software Developer

Read next

The Complete Guide to Vue 3 Composables
12 min read

The Complete Guide to Vue 3 Composables

Understand the power of Vue 3's Composition API through practical, real-world composable patterns that you can use today.

Mbeah EssilfieMbeah Essilfie
982