TypeScript Error Handling Patterns | Result Types· never
이 글의 핵심
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.
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. 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.
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
Keywords Covered in This Article (Related Search Terms)
This article covers TypeScript, Error Handling, JavaScript, Backend, Best Practices.