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:
catch (error)gives youunknown— no type info- Functions don’t declare what errors they can throw
- Errors are invisible in the type signature — callers don’t know to handle them
- 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
| Scenario | Pattern |
|---|---|
| Library/utility functions | Result type (neverthrow) |
| HTTP route handlers | throw HttpError + global handler |
| React components | Error Boundary |
| External API calls | Result type + Zod safe parse |
| Multiple parallel requests | Promise.allSettled |
| Type-safe switch dispatch | Discriminated union + never exhaustive check |
| Production monitoring | Sentry + custom error classes |
Key Takeaways
- Make errors visible — use Result types for functions that can fail in expected ways
- Type your errors — discriminated unions let TypeScript enforce exhaustive handling
- Validate at boundaries — parse external data with Zod, never trust types at runtime
- One global handler — catch unhandled errors in one place, never silently swallow
- Use
neverfor 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.