TypeScript Error Handling Patterns | Result Types, never, and Production Strategies

TypeScript Error Handling Patterns | Result Types, never, and Production Strategies

이 글의 핵심

TypeScript's type system can make error handling explicit and type-safe — no more silent catch blocks. This guide covers Result types, discriminated unions, and patterns that make errors visible and handleable.

Why TypeScript Error Handling Needs Improvement

The default JavaScript/TypeScript error handling has a critical problem:

// What is `error`? unknown. What can you do with it?
try {
  const data = await fetchUser(id)
  return data
} catch (error) {
  console.error(error)  // What type is this?
  return null           // silent failure — caller doesn't know
}

Problems:

  1. catch (error) gives you unknown — no type info
  2. Functions don’t declare what errors they can throw
  3. Errors are invisible in the type signature — callers don’t know to handle them
  4. Forgetting a try/catch compiles fine

1. The Result Type Pattern

Model errors as values, not exceptions:

// Define Result type
type Ok<T> = { success: true; data: T }
type Err<E> = { success: false; error: E }
type Result<T, E = Error> = Ok<T> | Err<E>

// Helper constructors
const ok = <T>(data: T): Ok<T> => ({ success: true, data })
const err = <E>(error: E): Err<E> => ({ success: false, error })

// Function that returns Result instead of throwing
async function fetchUser(id: string): Promise<Result<User, 'NOT_FOUND' | 'NETWORK_ERROR'>> {
  try {
    const response = await fetch(`/api/users/${id}`)
    if (response.status === 404) return err('NOT_FOUND')
    if (!response.ok) return err('NETWORK_ERROR')
    return ok(await response.json())
  } catch {
    return err('NETWORK_ERROR')
  }
}

// Caller must handle both cases — TypeScript enforces it
const result = await fetchUser('123')

if (result.success) {
  console.log(result.data.name)  // TypeScript knows this is User
} else {
  // TypeScript knows error is 'NOT_FOUND' | 'NETWORK_ERROR'
  switch (result.error) {
    case 'NOT_FOUND':
      return showNotFoundPage()
    case 'NETWORK_ERROR':
      return showRetryButton()
  }
}

2. neverthrow Library

neverthrow provides a production-ready Result type with chaining:

npm install neverthrow
import { ok, err, Result, ResultAsync } from 'neverthrow'

// Synchronous Result
function divide(a: number, b: number): Result<number, 'DIVISION_BY_ZERO'> {
  if (b === 0) return err('DIVISION_BY_ZERO')
  return ok(a / b)
}

// Async Result
function fetchUser(id: string): ResultAsync<User, ApiError> {
  return ResultAsync.fromPromise(
    fetch(`/api/users/${id}`).then(r => r.json()),
    (e) => ({ code: 'FETCH_ERROR', message: String(e) } as ApiError)
  )
}

// Chain Results (like Promise.then)
const result = await fetchUser('123')
  .map(user => ({ ...user, displayName: `${user.firstName} ${user.lastName}` }))
  .mapErr(err => ({ ...err, timestamp: Date.now() }))
  .match(
    (user) => `Hello, ${user.displayName}`,   // success branch
    (err) => `Error: ${err.message}`           // error branch
  )

// Combine multiple Results
import { combine, combineWithAllErrors } from 'neverthrow'

const results = combine([
  fetchUser('alice'),
  fetchUser('bob'),
  fetchUser('carol'),
])

if (results.isOk()) {
  const [alice, bob, carol] = results.value
}

3. Discriminated Unions for Error Types

Define specific, typed error types:

// Typed error union
type AppError =
  | { type: 'VALIDATION_ERROR'; field: string; message: string }
  | { type: 'NOT_FOUND'; resource: string; id: string }
  | { type: 'UNAUTHORIZED'; reason: string }
  | { type: 'RATE_LIMITED'; retryAfter: number }
  | { type: 'INTERNAL_ERROR'; error: Error }

// Handler with exhaustive type checking
function handleError(error: AppError): Response {
  switch (error.type) {
    case 'VALIDATION_ERROR':
      return Response.json({ field: error.field, message: error.message }, { status: 400 })
    case 'NOT_FOUND':
      return Response.json({ message: `${error.resource} not found` }, { status: 404 })
    case 'UNAUTHORIZED':
      return Response.json({ reason: error.reason }, { status: 401 })
    case 'RATE_LIMITED':
      return new Response(null, {
        status: 429,
        headers: { 'Retry-After': String(error.retryAfter) }
      })
    case 'INTERNAL_ERROR':
      console.error(error.error)
      return Response.json({ message: 'Internal server error' }, { status: 500 })
    default:
      // TypeScript ensures this is unreachable
      const _exhaustive: never = error
      return Response.json({ message: 'Unknown error' }, { status: 500 })
  }
}

The never assignment at the bottom is the exhaustiveness check — if you add a new error type without handling it, TypeScript shows an error.


4. Safe Parsing Pattern

Validate external data (API responses, user input) without trusting types:

import { z } from 'zod'

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'viewer']),
})

type User = z.infer<typeof UserSchema>

// Safe parse returns Result-like object
async function getUser(id: string): Promise<Result<User, AppError>> {
  const response = await fetch(`/api/users/${id}`)
  const raw = await response.json()

  const parsed = UserSchema.safeParse(raw)
  if (!parsed.success) {
    return err({
      type: 'VALIDATION_ERROR',
      field: parsed.error.issues[0]?.path.join('.') ?? 'unknown',
      message: parsed.error.message,
    })
  }

  return ok(parsed.data)
}

5. Error Boundaries in React

import { Component, ReactNode } from 'react'

interface Props { children: ReactNode; fallback: ReactNode }
interface State { hasError: boolean; error: Error | null }

class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false, error: null }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, info: { componentStack: string }) {
    // Log to error tracking service
    reportError(error, info)
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback
    }
    return this.props.children
  }
}

// Usage
<ErrorBoundary fallback={<ErrorPage message="Something went wrong" />}>
  <UserDashboard />
</ErrorBoundary>

6. Global Error Handler (Express / Hono)

// Express
import { Request, Response, NextFunction } from 'express'

interface AppError extends Error {
  statusCode?: number
  code?: string
}

app.use((err: AppError, req: Request, res: Response, next: NextFunction) => {
  const status = err.statusCode ?? 500
  const code = err.code ?? 'INTERNAL_ERROR'

  if (status >= 500) {
    console.error({ code, message: err.message, stack: err.stack })
  }

  res.status(status).json({
    error: { code, message: status < 500 ? err.message : 'Internal server error' },
    requestId: req.headers['x-request-id'],
  })
})

// Custom error class
class HttpError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
  ) {
    super(message)
    this.name = 'HttpError'
  }
}

// Usage in route handlers
app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await db.users.findById(req.params.id)
    if (!user) throw new HttpError(404, 'NOT_FOUND', 'User not found')
    res.json(user)
  } catch (err) {
    next(err)  // passes to global error handler
  }
})

7. Async Error Handling with Promise.allSettled

// When you need all results, even if some fail
const results = await Promise.allSettled([
  fetchUser('alice'),
  fetchUser('bob'),
  fetchUser('carol'),
])

const users: User[] = []
const errors: string[] = []

for (const result of results) {
  if (result.status === 'fulfilled') {
    users.push(result.value)
  } else {
    errors.push(result.reason.message)
  }
}

// vs Promise.all which fails fast on first error
try {
  const [alice, bob, carol] = await Promise.all([
    fetchUser('alice'),
    fetchUser('bob'),
    fetchUser('carol'),
  ])
} catch (err) {
  // only know about the first failure
}

8. Error Tracking in Production

// Sentry integration
import * as Sentry from '@sentry/node'

Sentry.init({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 0.1 })

// Capture with context
function handleCriticalError(error: Error, context: Record<string, unknown>) {
  Sentry.withScope(scope => {
    scope.setExtras(context)
    scope.setLevel('error')
    Sentry.captureException(error)
  })
}

// Custom error classes get better Sentry grouping
class DatabaseError extends Error {
  constructor(
    message: string,
    public query: string,
    public params: unknown[]
  ) {
    super(message)
    this.name = 'DatabaseError'
  }
}

Pattern Selection Guide

ScenarioPattern
Library/utility functionsResult type (neverthrow)
HTTP route handlersthrow HttpError + global handler
React componentsError Boundary
External API callsResult type + Zod safe parse
Multiple parallel requestsPromise.allSettled
Type-safe switch dispatchDiscriminated union + never exhaustive check
Production monitoringSentry + custom error classes

Key Takeaways

  1. Make errors visible — use Result types for functions that can fail in expected ways
  2. Type your errors — discriminated unions let TypeScript enforce exhaustive handling
  3. Validate at boundaries — parse external data with Zod, never trust types at runtime
  4. One global handler — catch unhandled errors in one place, never silently swallow
  5. Use never for exhaustiveness — TypeScript will catch missing cases at compile time

The most important shift: stop thinking of errors as exceptional — they’re just another return value. When errors are part of the type signature, the compiler helps you handle them. Silent failures become compile errors.