Skip to content

nest_10_filters - Exception Filters

Your API will throw errors The question isn't if but whether those errors come back as consistent JSON or as Express default HTML with stack traces that leak your entire application structure Exception filters catch thrown exceptions and format them into standardized responses - no stack traces to prod , no inconsistent error shapes , no debugging your error handling at 2AM

what's in here

  • Exception filter interface and @Catch decorator
  • Built-in HttpException and Nest's exception hierarchy
  • Custom exception classes
  • Global , controller , and method-scoped filters
  • Consistent error response formatting
  • Logging errors without leaking sensitive data

the exception filter interface

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common'
import { Request, Response } from 'express'

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const response = ctx.getResponse<Response>()
    const request = ctx.getRequest<Request>()
    const status = exception.getStatus()
    const exceptionResponse = exception.getResponse()

    const errorResponse = {
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      method: request.method,
      message: typeof exceptionResponse === 'string'
        ? exceptionResponse
        : (exceptionResponse as any).message || exception.message,
      // NEVER include stack trace in production responses
    }

    // log the error internally
    console.error(`[${request.method}] ${request.url} - ${status}: ${JSON.stringify(errorResponse)}`)

    response
      .status(status)
      .json(errorResponse)
  }
}

@Catch(HttpException) tells Nest which exception types this filter handles The catch() method receives the exception and the ArgumentsHost (which gives access to request/response) Every response follows the same structure - clients don't need to handle multiple error shapes

built-in HTTP exceptions

import {
  BadRequestException,        // 400
  UnauthorizedException,      // 401
  ForbiddenException,         // 403
  NotFoundException,          // 404
  MethodNotAllowedException,  // 405
  NotAcceptableException,     // 406
  RequestTimeoutException,    // 408
  ConflictException,          // 409
  GoneException,              // 410
  PayloadTooLargeException,   // 413
  UnsupportedMediaTypeException, // 415
  UnprocessableEntityException,  // 422
  InternalServerErrorException,  // 500
  ServiceUnavailableException,   // 503
} from '@nestjs/common'

@Post()
create(@Body() dto: CreateUserDto) {
  if (emailExists(dto.email)) {
    throw new ConflictException('email already registered')
  }

  if (isRateLimited()) {
    throw new TooManyRequestsException()  // 429
  }

  throw new InternalServerErrorException('something broke - we\'re looking into it')
  // note: generic message , no internal details leaked
}

Each exception maps to the correct HTTP status code The message is what the client sees - make it useful but don't leak internals Never expose stack traces , file paths , database query details , or dependency versions

custom exception classes

import { HttpException, HttpStatus } from '@nestjs/common'

export class BusinessRuleException extends HttpException {
  constructor(rule: string, details?: string) {
    const response = {
      error: 'BUSINESS_RULE_VIOLATION',
      rule,
      details,
      timestamp: new Date().toISOString()
    }
    super(response, HttpStatus.UNPROCESSABLE_ENTITY)
  }
}

// usage
if (user.accountBalance < order.total) {
  throw new BusinessRuleException(
    'INSUFFICIENT_FUNDS',
    `balance ${user.accountBalance} < required ${order.total}`
  )
}

Custom exceptions carry application-specific error context The error field lets clients programmatically handle different error types BusinessRuleException returns 422 with structured data instead of a generic 400

catching all exceptions (catch-all filter)

import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'
import { Request, Response } from 'express'

@Catch()  // catches EVERYTHING
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const response = ctx.getResponse<Response>()
    const request = ctx.getRequest<Request>()

    let status = HttpStatus.INTERNAL_SERVER_ERROR
    let message: any = 'internal server error'

    if (exception instanceof HttpException) {
      status = exception.getStatus()
      const exceptionResponse = exception.getResponse()
      message = typeof exceptionResponse === 'string'
        ? exceptionResponse
        : (exceptionResponse as any).message || message
    } else if (exception instanceof Error) {
      // log the real error with stack trace internally
      console.error(`[UNHANDLED] ${request.method} ${request.url}:`, exception.stack)
    }

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message
    })
  }
}

@Catch() with no arguments catches every exception type For HttpException - format the known response For unknown errors - log the stack trace internally but return only "internal server error" to the client This is your safety net - never let an unhandled error reach Express's default error handler

filter scopes

// method-scoped
@Post()
@UseFilters(new HttpExceptionFilter())
create() {}

// controller-scoped
@Controller('users')
@UseFilters(HttpExceptionFilter)  // class reference (DI enabled)
export class UsersController {}

// global in main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  app.useGlobalFilters(new AllExceptionsFilter())
  await app.listen(3000)
}

// global through DI (app.module.ts)
@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: AllExceptionsFilter
    }
  ]
})

Global filters catch everything - register once and never think about it Controller/method filters catch specific handlers - use for special error handling needs Passing the class reference (not instance) enables DI in the filter

consistent error response format

// Standard error response sent to client
{
  "statusCode": 422,
  "timestamp": "2026-03-15T03:12:44.123Z",
  "path": "/api/v1/users",
  "message": [
    "email must be a valid email address",
    "password must be at least 8 characters"
  ]
}

// Business rule violation
{
  "statusCode": 422,
  "timestamp": "2026-03-15T03:12:44.123Z",
  "path": "/api/v1/orders",
  "error": "BUSINESS_RULE_VIOLATION",
  "rule": "INSUFFICIENT_FUNDS",
  "details": "balance 50 < required 200"
}

// Auth failure
{
  "statusCode": 401,
  "timestamp": "2026-03-15T03:12:44.123Z",
  "path": "/api/v1/admin/users",
  "message": "invalid or expired token"
}

Consistent error shape means frontends write one error handler Validation errors have an array of messages (one per failed constraint) Auth errors don't reveal whether the user exists - just "invalid credentials"

security note: what NOT to include in errors

BAD - NEVER DO THIS:
{
  "statusCode": 500,
  "message": "Cannot read property 'id' of undefined",
  "stack": "TypeError: Cannot read property 'id' of undefined\n    at UsersService.findOne (src/users/users.service.ts:42:17)\n    at UsersController.findOne (src/users/users.controller.ts:15:9)",
  "query": "SELECT * FROM users WHERE id = '123'"
}

GOOD:
{
  "statusCode": 500,
  "message": "internal server error"
}

Stack traces leak file paths , line numbers , and sometimes credentials SQL queries in error messages expose your schema If you need more detail in development , use NODE_ENV checks to conditionally include information

prerequisites

nest_09_pipes - Pipes & Validation


next -> nest_11_auth - Authentication & Passport