Skip to content
ExponaBLOG
NESTJS · DAY 028 / 100

Exception filters for consistent errors, explained simply

Every error in one predictable shape.

Expona Admin10 min read

In production systems, nothing breaks user trust faster than inconsistent error messages. One endpoint returns a generic "Something went wrong," another returns a detailed stack trace, and a third returns raw database errors. Exception filters for consistent errors solve this by ensuring every error—whether it's a validation failure, a missing record, or an unexpected crash—comes back in the same predictable shape. This is where NestJS exception filters shine.

I've built platforms handling millions of transactions, and I can tell you: standardized error responses save weeks of debugging time and make your API feel professional. Let me walk you through how to implement this in your NestJS application.

Understanding built-in behavior

NestJS ships with built-in error handling. If you throw an HttpException or one of its subclasses, the framework automatically converts it to a JSON response with a status code. Everything else—unhandled errors, runtime exceptions—becomes a 500 Internal Server Error with a generic message.

Here's what you get out of the box:

typescript
throw new NotFoundException('User not found');
// Response:
// {
//   "statusCode": 404,
//   "message": "User not found",
//   "error": "Not Found"
// }

That's helpful, but in real applications you'll throw hundreds of different errors. Some are client mistakes (validation, authentication). Some are server problems (database outages, external API failures). Some expose sensitive information (stack traces, internal field names). The default behavior doesn't give you enough control.

Without a global strategy, your responses become chaotic. One service might return { message: "error" }, another { error: "something went wrong", code: "ERR_001" }, and another returns the full stack trace. Your frontend can't build a reliable error handler. Your support team can't trace issues. This is where a global exception filter becomes essential.

Building a global exception filter

A global exception filter intercepts every exception before it reaches the client. You decide the format, the status code, the logging behavior, and what information leaks out.

Here's a solid starting point:

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

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

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

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'Internal server error';

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse();
      message =
        typeof exceptionResponse === 'object'
          ? (exceptionResponse as any).message || 'Unknown error'
          : (exceptionResponse as string);
    } else if (exception instanceof Error) {
      message = exception.message;
    }

    // Log the error
    const logLevel = status >= 500 ? 'error' : 'warn';
    this.logger[logLevel]({
      timestamp: new Date().toISOString(),
      status,
      message,
      path: request.url,
      method: request.method,
      ...(status >= 500 && { stack: exception instanceof Error ? exception.stack : undefined }),
    });

    response.status(status).json({
      statusCode: status,
      message: status >= 500 ? 'Internal server error' : message,
      timestamp: new Date().toISOString(),
    });
  }
}

Register it in your main.ts:

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

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

Now every error returns the same shape: statusCode, message, and timestamp. Client errors (4xx) show the real message. Server errors (5xx) hide implementation details.

Mapping database errors with meaning

Raw database errors are useless to users and dangerous to expose. "Unique constraint violation on users.email" leaks your schema. But mapping Prisma errors to HTTP status codes gives users actionable feedback.

Prisma error codes tell you exactly what happened. P2002 is a unique constraint violation. P2025 is a record not found. P2003 is a foreign key constraint failure.

Here's how to enhance your filter:

typescript
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Response } from 'express';
import { Prisma } from '@prisma/client';

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

  private mapPrismaError(exception: Prisma.PrismaClientKnownRequestError) {
    const prismaErrorMap: Record = {
      P2002: {
        status: HttpStatus.CONFLICT,
        message: 'A record with this value already exists',
      },
      P2025: {
        status: HttpStatus.NOT_FOUND,
        message: 'Record not found',
      },
      P2003: {
        status: HttpStatus.BAD_REQUEST,
        message: 'Invalid reference to related record',
      },
      P2014: {
        status: HttpStatus.BAD_REQUEST,
        message: 'The change you are trying to make violates required relations',
      },
    };

    const mapped = prismaErrorMap[exception.code];
    return mapped || { status: HttpStatus.INTERNAL_SERVER_ERROR, message: 'Database error' };
  }

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

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'Internal server error';

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse();
      message =
        typeof exceptionResponse === 'object'
          ? (exceptionResponse as any).message || 'Unknown error'
          : (exceptionResponse as string);
    } else if (exception instanceof Prisma.PrismaClientKnownRequestError) {
      const mapped = this.mapPrismaError(exception);
      status = mapped.status;
      message = mapped.message;
    } else if (exception instanceof Error) {
      message = exception.message;
    }

    const logLevel = status >= 500 ? 'error' : 'warn';
    this.logger[logLevel]({
      timestamp: new Date().toISOString(),
      status,
      message,
      path: request.url,
      method: request.method,
      ...(status >= 500 && { stack: exception instanceof Error ? exception.stack : undefined }),
    });

    response.status(status).json({
      statusCode: status,
      message: status >= 500 ? 'Internal server error' : message,
      timestamp: new Date().toISOString(),
    });
  }
}

Now when a user tries to create a duplicate email, they get a 409 Conflict with "A record with this value already exists." Your frontend can handle 409s by showing "This email is already registered." Much better than "P2002 unique constraint violation on users_email_key."

Including trace IDs for support visibility

Here's something I learned the hard way: when a user says "I got an error at 3pm," your support team needs to find that exact error in logs within seconds. Without a correlation ID, you're searching blind.

Add a trace ID to every response and log. Link them together. Your support team becomes a hero.

typescript
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Response, Request } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { Prisma } from '@prisma/client';

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

  private mapPrismaError(exception: Prisma.PrismaClientKnownRequestError) {
    const prismaErrorMap: Record = {
      P2002: {
        status: HttpStatus.CONFLICT,
        message: 'A record with this value already exists',
      },
      P2025: {
        status: HttpStatus.NOT_FOUND,
        message: 'Record not found',
      },
      P2003: {
        status: HttpStatus.BAD_REQUEST,
        message: 'Invalid reference to related record',
      },
    };

    return prismaErrorMap[exception.code] || {
      status: HttpStatus.INTERNAL_SERVER_ERROR,
      message: 'Database error',
    };
  }

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

    // Generate or retrieve trace ID
    const traceId = (request as any).traceId || uuidv4();

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'Internal server error';

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse();
      message =
        typeof exceptionResponse === 'object'
          ? (exceptionResponse as any).message || 'Unknown error'
          : (exceptionResponse as string);
    } else if (exception instanceof Prisma.PrismaClientKnownRequestError) {
      const mapped = this.mapPrismaError(exception);
      status = mapped.status;
      message = mapped.message;
    } else if (exception instanceof Error) {
      message = exception.message;
    }

    const logPayload = {
      traceId,
      timestamp: new Date().toISOString(),
      status,
      message,
      path: request.url,
      method: request.method,
      ...(status >= 500 && {
        stack: exception instanceof Error ? exception.stack : undefined,
      }),
    };

    const logLevel = status >= 500 ? 'error' : 'warn';
    this.logger[logLevel](logPayload);

    response.status(status).json({
      statusCode: status,
      message: status >= 500 ? 'Internal server error' : message,
      timestamp: new Date().toISOString(),
      traceId,
    });
  }
}

Add middleware to ensure every request gets a trace ID:

typescript
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';

@Injectable()
export class TraceIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    (req as any).traceId = req.get('x-trace-id') || uuidv4();
    res.setHeader('x-trace-id', (req as any).traceId);
    next();
  }
}

Now every error response includes a traceId. A user reports "I got an error, here's my trace ID: 3a4b2c1d," and your support team searches logs for that ID and finds the full context instantly.

Common mistakes

  • Logging everything at ERROR level: Client errors (400, 404, 409) aren't server errors. Log them as WARN. Reserve ERROR for 5xx. This keeps your noise signal ratio healthy.
  • Exposing stack traces to users: A user doesn't need to see your code. Stack traces leak information attackers use. Hide them behind a trace ID.
  • Forgetting to catch non-HTTP exceptions: Database timeouts, JSON parse errors, and library exceptions don't inherit from HttpException. Your filter must catch unknown and handle them gracefully.
  • Not mapping domain-specific errors: A 400 for "Insufficient balance" is better than a 422 for "Validation failed." Map your business logic errors to meaningful status codes.
  • Losing trace IDs across async boundaries: If you're using async context or queues, ensure trace IDs propagate. Otherwise, logs become disconnected.

Key takeaways

  • Exception filters for consistent errors transform chaotic, unpredictable responses into a standard format every client can rely on.
  • Map database errors (especially Prisma codes like P2002, P2025) to meaningful HTTP status codes so users understand what happened.
  • Always include a trace ID in responses and logs. Support teams need it to investigate production issues in seconds, not hours.
  • Hide implementation details in 5xx responses; show helpful messages in 4xx responses.
  • Log at the right level: WARN for client errors, ERROR for server errors. This keeps your alerting signal clean.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles