Proper error handling distinguishes production-ready applications from prototypes. This guide covers error handling patterns for Node.js and TypeScript applications.
Custom Error Classes#
Error Hierarchy#
1// Base application error
2export class AppError extends Error {
3 public readonly isOperational: boolean;
4 public readonly statusCode: number;
5 public readonly code: string;
6
7 constructor(
8 message: string,
9 statusCode: number = 500,
10 code: string = 'INTERNAL_ERROR',
11 isOperational: boolean = true
12 ) {
13 super(message);
14 this.name = this.constructor.name;
15 this.statusCode = statusCode;
16 this.code = code;
17 this.isOperational = isOperational;
18
19 Error.captureStackTrace(this, this.constructor);
20 }
21}
22
23// Specific error types
24export class ValidationError extends AppError {
25 public readonly details: Array<{ field: string; message: string }>;
26
27 constructor(message: string, details: Array<{ field: string; message: string }> = []) {
28 super(message, 400, 'VALIDATION_ERROR');
29 this.details = details;
30 }
31}
32
33export class NotFoundError extends AppError {
34 constructor(resource: string, id?: string) {
35 const message = id
36 ? `${resource} with ID ${id} not found`
37 : `${resource} not found`;
38 super(message, 404, 'NOT_FOUND');
39 }
40}
41
42export class UnauthorizedError extends AppError {
43 constructor(message: string = 'Authentication required') {
44 super(message, 401, 'UNAUTHORIZED');
45 }
46}
47
48export class ForbiddenError extends AppError {
49 constructor(message: string = 'Access denied') {
50 super(message, 403, 'FORBIDDEN');
51 }
52}
53
54export class ConflictError extends AppError {
55 constructor(message: string) {
56 super(message, 409, 'CONFLICT');
57 }
58}
59
60export class RateLimitError extends AppError {
61 public readonly retryAfter: number;
62
63 constructor(retryAfter: number) {
64 super('Rate limit exceeded', 429, 'RATE_LIMIT_EXCEEDED');
65 this.retryAfter = retryAfter;
66 }
67}Result Type Pattern#
Type-Safe Error Handling#
1type Result<T, E = Error> =
2 | { success: true; data: T }
3 | { success: false; error: E };
4
5function ok<T>(data: T): Result<T, never> {
6 return { success: true, data };
7}
8
9function err<E>(error: E): Result<never, E> {
10 return { success: false, error };
11}
12
13// Usage in services
14async function getUserById(id: string): Promise<Result<User, NotFoundError>> {
15 const user = await db.users.findById(id);
16
17 if (!user) {
18 return err(new NotFoundError('User', id));
19 }
20
21 return ok(user);
22}
23
24// Usage in controllers
25async function getUser(req: Request, res: Response) {
26 const result = await getUserById(req.params.id);
27
28 if (!result.success) {
29 return res.status(result.error.statusCode).json({
30 code: result.error.code,
31 message: result.error.message,
32 });
33 }
34
35 return res.json(result.data);
36}With neverthrow Library#
1import { ok, err, Result, ResultAsync } from 'neverthrow';
2
3class UserService {
4 findById(id: string): ResultAsync<User, NotFoundError> {
5 return ResultAsync.fromPromise(
6 db.users.findById(id),
7 () => new NotFoundError('User', id)
8 ).andThen(user =>
9 user ? ok(user) : err(new NotFoundError('User', id))
10 );
11 }
12
13 create(data: CreateUserData): ResultAsync<User, ValidationError | ConflictError> {
14 return this.validateUser(data)
15 .asyncAndThen(validated =>
16 this.checkEmailUnique(validated.email)
17 .andThen(() => this.saveUser(validated))
18 );
19 }
20}
21
22// Chaining results
23const result = await userService.findById('123')
24 .andThen(user => orderService.findByUser(user.id))
25 .map(orders => ({ orders, count: orders.length }));
26
27result.match(
28 data => res.json(data),
29 error => res.status(error.statusCode).json({ error: error.message })
30);Express Error Handling#
Async Handler Wrapper#
1type AsyncHandler = (
2 req: Request,
3 res: Response,
4 next: NextFunction
5) => Promise<any>;
6
7function asyncHandler(fn: AsyncHandler): RequestHandler {
8 return (req, res, next) => {
9 Promise.resolve(fn(req, res, next)).catch(next);
10 };
11}
12
13// Usage
14app.get('/users/:id', asyncHandler(async (req, res) => {
15 const user = await userService.findById(req.params.id);
16
17 if (!user) {
18 throw new NotFoundError('User', req.params.id);
19 }
20
21 res.json(user);
22}));Global Error Handler#
1interface ErrorResponse {
2 code: string;
3 message: string;
4 details?: unknown;
5 stack?: string;
6}
7
8function errorHandler(
9 err: Error,
10 req: Request,
11 res: Response,
12 next: NextFunction
13): void {
14 // Log error
15 logger.error({
16 error: {
17 name: err.name,
18 message: err.message,
19 stack: err.stack,
20 },
21 request: {
22 method: req.method,
23 path: req.path,
24 query: req.query,
25 body: sanitize(req.body),
26 headers: {
27 'user-agent': req.headers['user-agent'],
28 'x-request-id': req.headers['x-request-id'],
29 },
30 },
31 });
32
33 // Handle operational errors
34 if (err instanceof AppError) {
35 const response: ErrorResponse = {
36 code: err.code,
37 message: err.message,
38 };
39
40 if (err instanceof ValidationError) {
41 response.details = err.details;
42 }
43
44 if (err instanceof RateLimitError) {
45 res.set('Retry-After', err.retryAfter.toString());
46 }
47
48 res.status(err.statusCode).json(response);
49 return;
50 }
51
52 // Handle Prisma errors
53 if (err.name === 'PrismaClientKnownRequestError') {
54 const prismaError = err as PrismaClientKnownRequestError;
55
56 if (prismaError.code === 'P2002') {
57 res.status(409).json({
58 code: 'CONFLICT',
59 message: 'A record with this value already exists',
60 });
61 return;
62 }
63
64 if (prismaError.code === 'P2025') {
65 res.status(404).json({
66 code: 'NOT_FOUND',
67 message: 'Record not found',
68 });
69 return;
70 }
71 }
72
73 // Handle validation errors (Zod, Joi, etc.)
74 if (err.name === 'ZodError') {
75 res.status(400).json({
76 code: 'VALIDATION_ERROR',
77 message: 'Invalid request data',
78 details: (err as ZodError).errors,
79 });
80 return;
81 }
82
83 // Unknown errors
84 const response: ErrorResponse = {
85 code: 'INTERNAL_ERROR',
86 message: process.env.NODE_ENV === 'production'
87 ? 'An unexpected error occurred'
88 : err.message,
89 };
90
91 if (process.env.NODE_ENV !== 'production') {
92 response.stack = err.stack;
93 }
94
95 res.status(500).json(response);
96}
97
98// Apply middleware
99app.use(errorHandler);Retry Patterns#
Exponential Backoff#
1interface RetryOptions {
2 maxAttempts: number;
3 baseDelayMs: number;
4 maxDelayMs: number;
5 retryOn?: (error: Error) => boolean;
6}
7
8async function withRetry<T>(
9 fn: () => Promise<T>,
10 options: RetryOptions
11): Promise<T> {
12 const { maxAttempts, baseDelayMs, maxDelayMs, retryOn } = options;
13
14 let lastError: Error;
15
16 for (let attempt = 1; attempt <= maxAttempts; attempt++) {
17 try {
18 return await fn();
19 } catch (error) {
20 lastError = error as Error;
21
22 // Check if we should retry
23 if (retryOn && !retryOn(lastError)) {
24 throw lastError;
25 }
26
27 if (attempt === maxAttempts) {
28 throw lastError;
29 }
30
31 // Calculate delay with exponential backoff and jitter
32 const delay = Math.min(
33 baseDelayMs * Math.pow(2, attempt - 1) + Math.random() * 100,
34 maxDelayMs
35 );
36
37 logger.warn({
38 message: 'Retrying operation',
39 attempt,
40 maxAttempts,
41 delay,
42 error: lastError.message,
43 });
44
45 await sleep(delay);
46 }
47 }
48
49 throw lastError!;
50}
51
52// Usage
53const data = await withRetry(
54 () => fetchFromExternalAPI(url),
55 {
56 maxAttempts: 3,
57 baseDelayMs: 1000,
58 maxDelayMs: 10000,
59 retryOn: (error) => {
60 // Retry on network errors and 5xx responses
61 return error.name === 'FetchError' ||
62 (error instanceof APIError && error.statusCode >= 500);
63 },
64 }
65);Circuit Breaker#
1enum CircuitState {
2 CLOSED = 'CLOSED',
3 OPEN = 'OPEN',
4 HALF_OPEN = 'HALF_OPEN',
5}
6
7class CircuitBreaker {
8 private state: CircuitState = CircuitState.CLOSED;
9 private failureCount: number = 0;
10 private lastFailureTime: number = 0;
11 private successCount: number = 0;
12
13 constructor(
14 private options: {
15 failureThreshold: number;
16 resetTimeout: number;
17 halfOpenRequests: number;
18 }
19 ) {}
20
21 async execute<T>(fn: () => Promise<T>): Promise<T> {
22 if (this.state === CircuitState.OPEN) {
23 if (Date.now() - this.lastFailureTime >= this.options.resetTimeout) {
24 this.state = CircuitState.HALF_OPEN;
25 this.successCount = 0;
26 } else {
27 throw new Error('Circuit breaker is OPEN');
28 }
29 }
30
31 try {
32 const result = await fn();
33 this.onSuccess();
34 return result;
35 } catch (error) {
36 this.onFailure();
37 throw error;
38 }
39 }
40
41 private onSuccess(): void {
42 if (this.state === CircuitState.HALF_OPEN) {
43 this.successCount++;
44 if (this.successCount >= this.options.halfOpenRequests) {
45 this.state = CircuitState.CLOSED;
46 this.failureCount = 0;
47 }
48 } else {
49 this.failureCount = 0;
50 }
51 }
52
53 private onFailure(): void {
54 this.failureCount++;
55 this.lastFailureTime = Date.now();
56
57 if (this.failureCount >= this.options.failureThreshold) {
58 this.state = CircuitState.OPEN;
59 }
60 }
61}Process-Level Error Handling#
1// Handle uncaught exceptions
2process.on('uncaughtException', (error: Error) => {
3 logger.fatal({ error }, 'Uncaught exception');
4
5 // Graceful shutdown
6 gracefulShutdown(1);
7});
8
9// Handle unhandled promise rejections
10process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => {
11 logger.error({ reason, promise }, 'Unhandled rejection');
12
13 // In production, you might want to crash
14 // throw reason;
15});
16
17// Graceful shutdown
18async function gracefulShutdown(exitCode: number = 0): Promise<void> {
19 logger.info('Starting graceful shutdown...');
20
21 // Stop accepting new connections
22 server.close();
23
24 // Close database connections
25 await db.$disconnect();
26
27 // Close Redis connections
28 await redis.quit();
29
30 logger.info('Shutdown complete');
31 process.exit(exitCode);
32}
33
34process.on('SIGTERM', () => gracefulShutdown(0));
35process.on('SIGINT', () => gracefulShutdown(0));Best Practices#
- Use typed errors: Create specific error classes for different scenarios
- Don't swallow errors: Always handle or propagate errors
- Log with context: Include request IDs, user IDs, and relevant data
- Fail fast: Validate inputs early and throw immediately
- Be consistent: Use the same error format throughout your API
- Provide actionable messages: Help users understand what went wrong
Conclusion#
Good error handling makes applications maintainable and debuggable. Use custom error classes for type safety, implement proper async handling, and ensure graceful degradation. Remember to handle both expected errors (validation, not found) and unexpected ones (bugs, infrastructure failures).