Skip to content

Part 3.4 — Error Handling, Pipes, Filters & Global Interceptors

Site Console Site Console
4 min read Updated Jan 20, 2026 Backend Development 0 comments

Even the best backend breaks — bad input, invalid IDs, database errors, or unexpected failures.
A production-ready backend isn’t one that never breaks; it's one that breaks cleanly, consistently, and observably.

In this post, you’ll learn how to structure backend behavior using:

  • Pipes (validation & transformation)

  • Exception filters (error shape)

  • Interceptors (logging, response envelopes, timing)

  • Built-in error types

  • Custom HTTP exceptions

By the end, your NestJS backend will behave predictably under every condition.


1. Pipes: Where Input Becomes Safe & Typed

You already activated global validation in Part 3.2:

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }),
);

This pipe enforces:

  • Only expected fields are accepted

  • DTO validation rules must pass

  • Primitive types auto-transform (e.g., "1"1)

You can also write custom pipes.

Example: Parse non-numeric IDs:

import { PipeTransform, BadRequestException } from '@nestjs/common';

export class ParseIdPipe implements PipeTransform {
  transform(value: string) {
    const parsed = Number(value);
    if (Number.isNaN(parsed)) {
      throw new BadRequestException('ID must be a number');
    }
    return parsed;
  }
}

Use:

@Get(':id')
findOne(@Param('id', ParseIdPipe) id: number) { ... }

This gives you precise control.


2. Exception Filters: Unified Error Shape

Without a filter, different errors produce different structures:

  • Prisma errors

  • Validation errors

  • NotFoundException

  • BadRequestException

  • Unexpected errors

Your frontend receives inconsistent shapes.

Create a global exception filter:

// src/common/filters/http-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
} from '@nestjs/common';

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

    let status = 500;
    let message = 'Internal server error';

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const resBody = exception.getResponse() as
        | string
        | { message: string | string[] };
      message = typeof resBody === 'string' ? resBody : resBody.message;
    }

    res.status(status).json({
      statusCode: status,
      message,
      timestamp: new Date().toISOString(),
    });
  }
}

Apply it globally:

app.useGlobalFilters(new GlobalExceptionFilter());

Now your errors always look like:

{
  "statusCode": 400,
  "message": ["name must not be empty"],
  "timestamp": "2025-11-18T10:34:00Z"
}

This consistency helps your React frontend handle errors easily.


3. Handling Prisma Errors Safely

Prisma throws its own error classes.
You can catch them inside your filter:

import { Prisma } from '@prisma/client';

if (exception instanceof Prisma.PrismaClientKnownRequestError) {
  if (exception.code === 'P2002') {
    status = 409;
    message = 'Unique constraint failed';
  }
}

Examples:

  • P2002 → Unique violation

  • P2025 → Record not found

  • P2003 → Foreign key constraint failed

Mapping backend errors to clear messages improves UX dramatically.


4. Interceptors: Wrapping & Transforming Responses

Interceptors let you:

  • Add response envelopes

  • Log incoming/outgoing data

  • Time requests

  • Modify responses

  • Cache results

Example: Logging + Timing Interceptor

// src/common/interceptors/logging.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { tap } from 'rxjs/operators';
import { Observable } from 'rxjs';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const now = Date.now();
    const req = ctx.switchToHttp().getRequest();
    const { method, url } = req;

    return next.handle().pipe(
      tap(() => {
        console.log(
          `${method} ${url} - ${Date.now() - now}ms`,
        );
      }),
    );
  }
}

Apply globally:

app.useGlobalInterceptors(new LoggingInterceptor());

This gives clear logs like:

GET /users - 12ms
POST /posts - 33ms

5. Response Transformation Interceptor (Optional)

Some teams prefer enveloped responses:

{
  "data": { ... },
  "success": true
}

Example:

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(_: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      map(data => ({
        success: true,
        data,
      })),
    );
  }
}

Apply globally to unify your frontend’s response expectations.


6. Custom Exceptions for Business Logic

NestJS gives you:

throw new NotFoundException('User not found');
throw new BadRequestException('Invalid input');
throw new ForbiddenException('You cannot edit this post');

Use custom exceptions for clarity:

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

export class AuthorMismatchException extends HttpException {
  constructor() {
    super('Author mismatch', HttpStatus.FORBIDDEN);
  }
}

Use in service:

if (post.authorId !== userId) {
  throw new AuthorMismatchException();
}

Readable. Self-explanatory. Maintainable.


7. Global Validation Exception Customization

Want uniform validation errors?
You can override the default NestJS validation exception:

export class CustomValidationException extends BadRequestException {
  constructor(errors: any) {
    super({
      message: errors.map((e: any) => Object.values(e.constraints)).flat(),
    });
  }
}

Then:

app.useGlobalPipes(
  new ValidationPipe({
    exceptionFactory: (errors) => new CustomValidationException(errors),
  }),
);

Your frontend now receives validation errors in a clean, predictable format.


8. Ensuring Transformations Are Safe

Always validate transformed input:

  • ParseIntPipe

  • ParseBoolPipe

  • ParseArrayPipe

Example:

@Query('limit', new ParseIntPipe({ errorHttpStatusCode: 400 }))
limit: number

Do not trust raw query strings.


9. Combine Everything Into a Production Pipeline

Your NestJS stack now handles:

  1. DTO validation

  2. Whitelist + transform pipes

  3. Custom ID parsing

  4. Global exception filter

  5. Prisma error mapping

  6. Logging interceptor

  7. Response wrappers

A clean backend feels stable even when things go wrong.


10. Summary

You’ve built:

  • Robust DTO validation

  • Predictable global error handling

  • Structured responses

  • Prisma-safe error mapping

  • Logging & request timing

  • Transformation and safety via pipes

  • A backend your frontend can trust

In Part 3.5, you’ll connect these REST endpoints to your React clients and create shared API contracts across the full stack.

Related

Leave a comment

Sign in to leave a comment.

Comments