Skip to content

Exception Filters and Error Handling

Aman Kumar Singh9 min read
Part 9 of 40From the NestJS Production Guide series
Exception Filters and Error Handling — article by Aman Kumar Singh

Last time in the NestJS Production Guide, I covered Custom Pipes and Transformation, which is about shaping and validating input before it reaches a handler. Error handling is the other half of that contract. Once something goes wrong, whether it's a bad pipe, a failed query, or a downstream service timing out, the question becomes what the client actually sees and how much of the failure you're willing to expose.

Most teams start with whatever error format the framework throws by default, and that's fine for a prototype. It stops being fine the moment a frontend client, a mobile app, and an internal script all need to parse errors consistently, or the moment a stack trace containing a database connection string ends up in a client-facing response. Exception filters fix both problems in one place. No more scattering try/catch blocks across every controller.

I want to walk through how the exception layer actually works, how to build a global filter that gives you one consistent error shape, and where teams get bitten by details that don't show up until production traffic hits an edge case nobody tested.

How NestJS handles exceptions by default

NestJS ships with a built-in exception layer that catches anything thrown during the request lifecycle, whether that's inside a pipe, a guard, an interceptor, or the handler itself. If you throw an instance of HttpException (or one of its subclasses like NotFoundException or BadRequestException), NestJS reads the status code and message off it and serializes a JSON response automatically. If you throw anything else, a plain Error, a database driver exception, an unexpected TypeError, it falls back to a generic 500 with a message that varies by environment.

// src/modules/invoices/invoices.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InvoicesRepository } from './invoices.repository';

@Injectable()
export class InvoicesService {
  constructor(private readonly invoicesRepository: InvoicesRepository) {}

  async findOne(id: string) {
    const invoice = await this.invoicesRepository.findById(id);
    if (!invoice) {
      throw new NotFoundException(`Invoice ${id} not found`);
    }
    return invoice;
  }
}

This works fine as long as every error you throw is deliberately an HttpException. The trouble starts with everything you didn't anticipate: a PostgreSQL unique constraint violation, a Redis connection drop, a third-party API returning malformed JSON. Those surface as raw errors with a default 500 response, and the default body shape for that case is not something you want a client depending on long term. It's an internal implementation detail leaking through, and it changes between NestJS versions.

Building a global exception filter

An exception filter is a class that implements ExceptionFilter and gets a chance to intercept anything thrown during request handling, decide on a status code, and shape the response body before it goes out. Registering one globally means every unhandled error in the application, regardless of where it originated, passes through the same logic.

// src/common/filters/all-exceptions.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';

interface ErrorResponseBody {
  statusCode: number;
  message: string;
  error: string;
  path: string;
  timestamp: string;
  requestId?: string;
}

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name);

  catch(exception: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    const { statusCode, message, error } = this.resolveException(exception);

    const body: ErrorResponseBody = {
      statusCode,
      message,
      error,
      path: request.url,
      timestamp: new Date().toISOString(),
      requestId: request.headers['x-request-id'] as string | undefined,
    };

    if (statusCode >= HttpStatus.INTERNAL_SERVER_ERROR) {
      this.logger.error(
        `Unhandled exception on ${request.method} ${request.url}`,
        exception instanceof Error ? exception.stack : String(exception),
      );
    }

    response.status(statusCode).json(body);
  }

  private resolveException(exception: unknown): { statusCode: number; message: string; error: string } {
    if (exception instanceof HttpException) {
      const status = exception.getStatus();
      const response = exception.getResponse();
      const message =
        typeof response === 'string'
          ? response
          : ((response as Record<string, unknown>).message as string) ?? exception.message;

      return { statusCode: status, message, error: HttpStatus[status] ?? 'Error' };
    }

    return {
      statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
      message: 'An unexpected error occurred',
      error: 'Internal Server Error',
    };
  }
}

The @Catch() decorator with no argument means this filter catches everything, HttpException instances and raw JavaScript errors alike. That's the key difference from a filter scoped to a specific exception type: this one is the safety net at the bottom of the stack. It's the reason a raw database error never reaches a client as an unformatted stack trace. Notice the message for unhandled, non-HttpException errors is deliberately generic. The real error, including its stack trace, goes to the logger, not the response body.

Registering it globally happens in the bootstrap function rather than as a module provider, because exception filters applied via app.useGlobalFilters run outside the regular dependency injection scoping that per-route filters use.

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new AllExceptionsFilter());
  await app.listen(3000);
}
bootstrap();

Domain-specific filters for structured errors

A single global filter handles the catch-all case, but some errors deserve their own shape. Database constraint violations are a good example: a unique key conflict on an email column and a foreign key violation on a tenant reference are both database errors, but they mean different things to the client and probably map to different HTTP statuses.

// src/common/filters/typeorm-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common';
import { QueryFailedError } from 'typeorm';
import { Response } from 'express';

const POSTGRES_UNIQUE_VIOLATION = '23505';
const POSTGRES_FOREIGN_KEY_VIOLATION = '23503';

@Catch(QueryFailedError)
export class TypeOrmExceptionFilter implements ExceptionFilter {
  catch(exception: QueryFailedError & { code?: string }, host: ArgumentsHost): void {
    const response = host.switchToHttp().getResponse<Response>();

    if (exception.code === POSTGRES_UNIQUE_VIOLATION) {
      response.status(HttpStatus.CONFLICT).json({
        statusCode: HttpStatus.CONFLICT,
        message: 'A record with this value already exists',
        error: 'Conflict',
      });
      return;
    }

    if (exception.code === POSTGRES_FOREIGN_KEY_VIOLATION) {
      response.status(HttpStatus.BAD_REQUEST).json({
        statusCode: HttpStatus.BAD_REQUEST,
        message: 'Referenced record does not exist',
        error: 'Bad Request',
      });
      return;
    }

    response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
      statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
      message: 'A database error occurred',
      error: 'Internal Server Error',
    });
  }
}

Register this one alongside the catch-all filter, not instead of it. NestJS applies filters in the order that best matches the thrown exception, so a QueryFailedError hits this filter first and anything else falls through to AllExceptionsFilter. This is a case where I'd defer building it until a real project actually hits repeated constraint violations that need distinct client-facing messages. A small SaaS with one or two unique constraints doesn't need this layer on day one; a system with several tenant-scoped uniqueness rules and a support team fielding "why did my signup fail" tickets does.

Custom exception classes for domain errors

HttpException subclasses like NotFoundException and ConflictException cover generic HTTP semantics, but they don't carry domain context. A custom exception class lets you throw something specific, like InsufficientCreditsException, and have the filter layer translate it into the right status and message without the service layer knowing anything about HTTP.

// src/modules/billing/exceptions/insufficient-credits.exception.ts
import { HttpException, HttpStatus } from '@nestjs/common';

export class InsufficientCreditsException extends HttpException {
  constructor(required: number, available: number) {
    super(
      {
        statusCode: HttpStatus.PAYMENT_REQUIRED,
        message: `Insufficient credits: ${required} required, ${available} available`,
        error: 'InsufficientCredits',
      },
      HttpStatus.PAYMENT_REQUIRED,
    );
  }
}

The service throws this directly, with no knowledge of response formatting:

if (account.credits < requiredCredits) {
  throw new InsufficientCreditsException(requiredCredits, account.credits);
}

Because InsufficientCreditsException extends HttpException, the global filter already knows how to handle it without any extra code. This is the pattern worth reaching for once you notice the same conditional error logic (checking a status code, building a specific message) repeated across multiple controllers. Before that point, a plain HttpException with a clear message is often good enough, and adding a custom class for every possible failure mode is complexity you're paying for before you need it.

Tradeoffs and what not to over-engineer

It's tempting to build an elaborate error taxonomy early: error codes, i18n message keys, a shared error registry synced between backend and frontend. For a small team shipping a SaaS product, that's usually premature. A consistent response shape (status code, message, error label, timestamp, maybe a request ID for correlation with logs) covers almost every real need. Add structure incrementally as specific pain points show up, like a frontend team asking for a stable machine-readable error code because they're matching on the message string and it keeps changing.

The one thing worth getting right early is never leaking internal detail in the response body. Stack traces, SQL fragments, internal service hostnames: none of that belongs in a client-facing 500. Log it, don't serialize it.

Production pitfalls

The most common mistake is having multiple @Catch() filters that overlap in scope without a clear catch-all underneath. If a raw TypeError slips past every specific filter because none of them matches its type, and there's no global @Catch() filter as a backstop, NestJS falls through to its own default handler, which brings back the unformatted response shape you built the filters to avoid.

The second is forgetting that app.useGlobalFilters() filters don't have access to dependency injection the same way a filter registered via APP_FILTER in a module does. If your filter needs an injected service, like a logger sink or a notification service for alerting on 500s, use the APP_FILTER provider token instead:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: AllExceptionsFilter,
    },
  ],
})
export class AppModule {}

The third is logging too much or too little. Logging every 4xx at error level fills your logs with noise from normal client mistakes, like a 404 for a resource a user typo'd. Logging only 5xx at error level and everything else at a lower level, or not at all, keeps the signal usable. Conversely, swallowing the original error object and logging only a generic message means a real production incident shows up in your logs with no stack trace to debug it.

The fourth is inconsistent error shapes across different parts of the API, usually because someone added a new module and skipped registering the shared filter, or a WebSocket gateway needs its own exception handling entirely (HTTP filters don't apply to gateways) and nobody built the equivalent for that transport. Audit this whenever a new transport or module type gets added to the app.

Key takeaways

  • NestJS's default exception handling only produces a clean response for `HttpException` instances; anything else falls back to a generic 500 whose shape you don't control.
  • A global filter with `@Catch()` and no argument is the safety net that guarantees a consistent response shape regardless of what was thrown.
  • Domain-specific filters, like one for database constraint violations, are worth adding once you have real, recurring cases that need distinct client messaging, not preemptively.
  • Custom exception classes that extend `HttpException` let the service layer stay ignorant of HTTP concerns while still producing a well-formed response through the existing filter.
  • Never serialize stack traces or internal error detail into a client-facing response body. Log the real error, return a generic message for 5xx failures.
  • Filters registered with `APP_FILTER` get full dependency injection; filters registered via `app.useGlobalFilters()` in `main.ts` do not.

Frequently asked questions

What's the difference between an exception filter and a try/catch block in a service?

A try/catch in a service handles errors local to that specific operation, often to add context or retry logic before rethrowing. An exception filter is a cross-cutting concern that runs after something is thrown anywhere in the request pipeline, guaranteeing every unhandled error, regardless of origin, gets the same response formatting.

Do exception filters catch errors thrown in middleware?

No. Middleware runs before NestJS builds its execution context, so errors thrown there bypass the Nest exception layer entirely and are handled by the underlying HTTP framework (Express or Fastify) instead. If you need consistent formatting for middleware errors, handle them explicitly inside the middleware itself.

Should I return the actual error message from a caught exception to the client?

Only for errors you threw deliberately as `HttpException` instances with a client-safe message. For anything unexpected, a database error, a null reference, a third-party API failure, return a generic message and log the real detail server-side. The client doesn't need to know your database driver's internal error text.

How do I handle exceptions differently for WebSocket gateways versus HTTP controllers?

HTTP exception filters don't apply to WebSocket gateways automatically. NestJS provides a separate `WsExceptionFilter` interface and `@Catch()` decorator usage for gateways, and you register it the same way but scoped to your gateway class rather than globally through the HTTP pipeline.

What's the right HTTP status code for a validation error versus a business rule violation?

Validation errors (malformed input, missing required fields) map to 400 Bad Request, typically handled automatically by NestJS's built-in `ValidationPipe`. Business rule violations that depend on state, like insufficient credits or a resource already existing, are usually 402, 409, or 422 depending on the specific case, and are worth their own named exception rather than a generic 400.

Is it worth building a shared error code system across frontend and backend early on?

Usually not on day one. A consistent response shape with a clear message is enough for most early-stage products. Add a formal error code registry once a specific consumer, like a frontend team building error-specific UI states, actually needs to match on something more stable than a message string.

Related articles

Controllers and Routing — Aman Kumar Singh
Security Best Practices — Aman Kumar Singh
Unit Testing Services — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.