Error Handling Pattern
Implement consistent, type-safe error handling across your application with custom error classes and handlers.
Overview#
Proper error handling improves debugging, user experience, and application reliability. This pattern provides a structured approach to creating, throwing, catching, and displaying errors consistently.
When to use:
- API error responses
- Client-side error handling
- Error logging and monitoring
- User-friendly error messages
- Validation error display
Key features:
- Custom error classes
- Consistent error format
- API error wrapper
- Client error handling
- Error logging
- React error boundaries
Code Example#
Custom Error Classes#
1// lib/errors.ts
2export class AppError extends Error {
3 constructor(
4 message: string,
5 public statusCode: number = 500,
6 public code: string = 'INTERNAL_ERROR'
7 ) {
8 super(message)
9 this.name = 'AppError'
10 }
11}
12
13export class NotFoundError extends AppError {
14 constructor(resource: string) {
15 super(`${resource} not found`, 404, 'NOT_FOUND')
16 this.name = 'NotFoundError'
17 }
18}
19
20export class UnauthorizedError extends AppError {
21 constructor(message = 'Unauthorized') {
22 super(message, 401, 'UNAUTHORIZED')
23 this.name = 'UnauthorizedError'
24 }
25}
26
27export class ForbiddenError extends AppError {
28 constructor(message = 'Forbidden') {
29 super(message, 403, 'FORBIDDEN')
30 this.name = 'ForbiddenError'
31 }
32}
33
34export class ValidationError extends AppError {
35 constructor(
36 message: string,
37 public errors: Record<string, string[]> = {}
38 ) {
39 super(message, 400, 'VALIDATION_ERROR')
40 this.name = 'ValidationError'
41 }
42}
43
44export class ConflictError extends AppError {
45 constructor(message: string) {
46 super(message, 409, 'CONFLICT')
47 this.name = 'ConflictError'
48 }
49}
50
51export class RateLimitError extends AppError {
52 constructor(
53 public retryAfter: number,
54 message = 'Too many requests'
55 ) {
56 super(message, 429, 'RATE_LIMIT_EXCEEDED')
57 this.name = 'RateLimitError'
58 }
59}Error Response Format#
1// lib/api-response.ts
2import { AppError, ValidationError } from './errors'
3
4interface ErrorResponse {
5 error: {
6 message: string
7 code: string
8 details?: Record<string, unknown>
9 }
10}
11
12interface SuccessResponse<T> {
13 data: T
14}
15
16export function errorResponse(
17 error: AppError | Error
18): { body: ErrorResponse; status: number } {
19 if (error instanceof AppError) {
20 return {
21 body: {
22 error: {
23 message: error.message,
24 code: error.code,
25 details: error instanceof ValidationError
26 ? { errors: error.errors }
27 : undefined
28 }
29 },
30 status: error.statusCode
31 }
32 }
33
34 // Unknown error - don't expose details in production
35 console.error('Unhandled error:', error)
36 return {
37 body: {
38 error: {
39 message: process.env.NODE_ENV === 'development'
40 ? error.message
41 : 'An unexpected error occurred',
42 code: 'INTERNAL_ERROR'
43 }
44 },
45 status: 500
46 }
47}
48
49export function successResponse<T>(data: T): SuccessResponse<T> {
50 return { data }
51}API Error Handler Wrapper#
1// lib/api-handler.ts
2import { NextRequest, NextResponse } from 'next/server'
3import { AppError, ValidationError, errorResponse } from '@/lib/errors'
4import { ZodError } from 'zod'
5
6type Handler = (request: NextRequest, context?: any) => Promise<Response>
7
8export function withErrorHandler(handler: Handler): Handler {
9 return async (request: NextRequest, context?: any) => {
10 try {
11 return await handler(request, context)
12 } catch (error) {
13 // Handle Zod validation errors
14 if (error instanceof ZodError) {
15 const validationError = new ValidationError(
16 'Validation failed',
17 formatZodErrors(error)
18 )
19 const { body, status } = errorResponse(validationError)
20 return NextResponse.json(body, { status })
21 }
22
23 // Handle app errors
24 if (error instanceof AppError) {
25 const { body, status } = errorResponse(error)
26 return NextResponse.json(body, { status })
27 }
28
29 // Handle unknown errors
30 console.error('Unhandled API error:', error)
31 return NextResponse.json(
32 {
33 error: {
34 message: 'Internal server error',
35 code: 'INTERNAL_ERROR'
36 }
37 },
38 { status: 500 }
39 )
40 }
41 }
42}
43
44function formatZodErrors(error: ZodError): Record<string, string[]> {
45 const errors: Record<string, string[]> = {}
46
47 for (const issue of error.issues) {
48 const path = issue.path.join('.')
49 if (!errors[path]) {
50 errors[path] = []
51 }
52 errors[path].push(issue.message)
53 }
54
55 return errors
56}Route Handler Usage#
1// app/api/users/[id]/route.ts
2import { NextRequest, NextResponse } from 'next/server'
3import { withErrorHandler } from '@/lib/api-handler'
4import { NotFoundError, ForbiddenError, UnauthorizedError } from '@/lib/errors'
5import { auth } from '@/lib/auth'
6import { prisma } from '@/lib/db'
7
8export const GET = withErrorHandler(async (
9 request: NextRequest,
10 { params }: { params: { id: string } }
11) => {
12 const { userId } = await auth()
13
14 if (!userId) {
15 throw new UnauthorizedError()
16 }
17
18 const user = await prisma.user.findUnique({
19 where: { id: params.id }
20 })
21
22 if (!user) {
23 throw new NotFoundError('User')
24 }
25
26 if (user.id !== userId) {
27 throw new ForbiddenError('Cannot access other users')
28 }
29
30 return NextResponse.json({ data: user })
31})Client-Side Error Handling#
1// lib/api-client.ts
2import { AppError } from './errors'
3
4interface ApiError {
5 error: {
6 message: string
7 code: string
8 details?: Record<string, unknown>
9 }
10}
11
12export async function apiRequest<T>(
13 url: string,
14 options?: RequestInit
15): Promise<T> {
16 const response = await fetch(url, {
17 ...options,
18 headers: {
19 'Content-Type': 'application/json',
20 ...options?.headers
21 }
22 })
23
24 const data = await response.json()
25
26 if (!response.ok) {
27 const apiError = data as ApiError
28 throw new AppError(
29 apiError.error.message,
30 response.status,
31 apiError.error.code
32 )
33 }
34
35 return data.data as T
36}
37
38// Usage
39async function fetchUser(id: string) {
40 try {
41 const user = await apiRequest<User>(`/api/users/${id}`)
42 return user
43 } catch (error) {
44 if (error instanceof AppError) {
45 if (error.code === 'NOT_FOUND') {
46 // Handle not found
47 }
48 if (error.code === 'UNAUTHORIZED') {
49 // Redirect to login
50 }
51 }
52 throw error
53 }
54}Error Logging#
1// lib/error-logger.ts
2import { AppError } from './errors'
3
4interface ErrorContext {
5 userId?: string
6 requestId?: string
7 path?: string
8 method?: string
9 userAgent?: string
10}
11
12export function logError(error: Error, context: ErrorContext = {}) {
13 const isOperational = error instanceof AppError
14
15 const logData = {
16 message: error.message,
17 name: error.name,
18 stack: error.stack,
19 isOperational,
20 code: error instanceof AppError ? error.code : undefined,
21 ...context,
22 timestamp: new Date().toISOString()
23 }
24
25 if (isOperational) {
26 // Expected errors - warn level
27 console.warn('Operational error:', JSON.stringify(logData))
28 } else {
29 // Unexpected errors - error level
30 console.error('System error:', JSON.stringify(logData))
31
32 // Send to error tracking service in production
33 if (process.env.NODE_ENV === 'production') {
34 // sendToSentry(error, context)
35 // sendToDatadog(error, context)
36 }
37 }
38}React Error Boundary#
1// components/ErrorBoundary.tsx
2'use client'
3
4import { Component, ReactNode } from 'react'
5
6interface Props {
7 children: ReactNode
8 fallback?: ReactNode
9}
10
11interface State {
12 hasError: boolean
13 error?: Error
14}
15
16export class ErrorBoundary extends Component<Props, State> {
17 constructor(props: Props) {
18 super(props)
19 this.state = { hasError: false }
20 }
21
22 static getDerivedStateFromError(error: Error): State {
23 return { hasError: true, error }
24 }
25
26 componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
27 console.error('Error caught by boundary:', error, errorInfo)
28 }
29
30 render() {
31 if (this.state.hasError) {
32 return this.props.fallback ?? (
33 <div className="p-4 text-center">
34 <h2 className="text-lg font-semibold text-red-600">
35 Something went wrong
36 </h2>
37 <p className="text-gray-600">
38 {this.state.error?.message ?? 'An unexpected error occurred'}
39 </p>
40 <button
41 onClick={() => this.setState({ hasError: false })}
42 className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
43 >
44 Try again
45 </button>
46 </div>
47 )
48 }
49
50 return this.props.children
51 }
52}Error Display Component#
1// components/ErrorMessage.tsx
2interface ErrorMessageProps {
3 error?: string | null
4 className?: string
5}
6
7export function ErrorMessage({ error, className }: ErrorMessageProps) {
8 if (!error) return null
9
10 return (
11 <p
12 className={`text-sm text-red-600 ${className ?? ''}`}
13 role="alert"
14 >
15 {error}
16 </p>
17 )
18}
19
20// Field error for forms
21interface FieldErrorProps {
22 name: string
23 errors?: Record<string, string>
24}
25
26export function FieldError({ name, errors }: FieldErrorProps) {
27 const error = errors?.[name]
28 if (!error) return null
29
30 return (
31 <p
32 className="mt-1 text-sm text-red-600"
33 role="alert"
34 id={`${name}-error`}
35 >
36 {error}
37 </p>
38 )
39}Toast Notifications for Errors#
1// lib/toast.ts
2import { toast } from 'sonner'
3import { AppError } from './errors'
4
5export function showErrorToast(error: unknown) {
6 if (error instanceof AppError) {
7 toast.error(error.message, {
8 description: getErrorDescription(error.code)
9 })
10 } else if (error instanceof Error) {
11 toast.error('An error occurred', {
12 description: error.message
13 })
14 } else {
15 toast.error('An unexpected error occurred')
16 }
17}
18
19function getErrorDescription(code: string): string | undefined {
20 const descriptions: Record<string, string> = {
21 UNAUTHORIZED: 'Please sign in to continue',
22 FORBIDDEN: 'You do not have permission for this action',
23 NOT_FOUND: 'The requested resource was not found',
24 RATE_LIMIT_EXCEEDED: 'Please wait before trying again',
25 VALIDATION_ERROR: 'Please check your input and try again'
26 }
27 return descriptions[code]
28}Usage Instructions#
- Create error classes: Define custom errors for each error type
- Wrap API handlers: Use
withErrorHandlerfor consistent responses - Throw specific errors: Use the right error class for each situation
- Handle on client: Catch errors and display appropriate messages
- Log appropriately: Track errors for debugging and monitoring
Best Practices#
- Use specific error classes - Create errors for each scenario
- Include error codes - Enable programmatic error handling
- Don't leak internal details - Sanitize messages in production
- Log with context - Include request ID, user ID, etc.
- Handle gracefully - Always show user-friendly messages
- Use error boundaries - Prevent component crashes from breaking the app
- Monitor in production - Use Sentry, Datadog, or similar
- Test error paths - Verify error handling works correctly
Related Patterns#
- Validation - Input validation errors
- API Error Handling - REST API errors
- Forms - Form error display