mdashikjs/blog
All posts
Structured Error Handling in NestJS: Beyond Try-Catch
Backend

Structured Error Handling in NestJS: Beyond Try-Catch

Backend4 min

Structured Error Handling in NestJS: Beyond Try-Catch

Most error handling I see in Node.js APIs is ad hoc try-catch blocks that return inconsistent shapes. Here is the pattern I use to make errors predictable, debuggable, and client-friendly.

NestJSError HandlingAPI DesignTypeScript
Share:

The problem with ad hoc errors

Endpoint A returns { error: "Not found" }. Endpoint B returns { message: "Not found", code: 404 }. Endpoint C throws and lets the framework return { statusCode: 500, message: "Internal server error" }. The frontend has to handle three different shapes.

A consistent error envelope

Every error response from our API looks like this:

interface ApiError {
  statusCode: number;
  code: string;        // machine-readable: 'RESOURCE_NOT_FOUND'
  message: string;     // human-readable: 'The requested user was not found'
  details?: unknown;   // validation errors, debug info, etc.
}

Custom exception classes

export class AppException extends HttpException {
  constructor(
    public readonly code: string,
    message: string,
    statusCode: number,
    public readonly details?: unknown,
  ) {
    super({ statusCode, code, message, details }, statusCode);
  }
}

export class NotFoundException extends AppException {
  constructor(resource: string, id: string) {
    super(
      'RESOURCE_NOT_FOUND',
      `${resource} with id ${id} was not found`,
      404,
    );
  }
}

export class ValidationException extends AppException {
  constructor(errors: Record<string, string[]>) {
    super('VALIDATION_ERROR', 'Request validation failed', 422, errors);
  }
}

Global exception filter

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();

    if (exception instanceof AppException) {
      return response.status(exception.getStatus()).json({
        statusCode: exception.getStatus(),
        code: exception.code,
        message: exception.message,
        details: exception.details,
      });
    }

    // Unexpected errors — log and return generic 500
    console.error('Unhandled exception:', exception);
    return response.status(500).json({
      statusCode: 500,
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
    });
  }
}

Why this matters for clients

The frontend can switch on code instead of parsing message strings. Adding a new error type never breaks existing error handling. And the details field carries structured validation errors that can be mapped directly to form fields.

Logging and observability

The global filter is the perfect place to log errors with context: request ID, user ID, endpoint, duration. We send all 5xx errors to our alerting pipeline and sample 4xx errors for analytics. The structured code field makes it easy to aggregate errors by type.

MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.