XplormityXplormity
HomeHandbooks
Browse
Xplormity

TLDR developer handbooks for
seasoned developers.

Handbooks

RustNestJSNext.jsGitDockerTypeScriptReactNode.jsDSASQLSystem DesignTailwind CSS

Site

HomeHandbooksAboutPrivacyTerms

Connect

GitHubTwitterLinkedIn

© 2026 Xplormity. All rights reserved.

HandbooksNestJSInterceptors, Pipes & Exception Filters

Interceptors, Pipes & Exception Filters

interceptorspipesfiltersrequest-lifecyclehandbook

TL;DR

  • Pipes: Transform/validate input data BEFORE the route handler.
  • Interceptors: Transform output data or add logic AROUND the route handler.
  • Exception Filters: Catch and format errors AFTER the route handler throws.
  • Execution order: Guards → Interceptors (before) → Pipes → Handler → Interceptors (after) → Filters.

Step 1: Request Lifecycle

Understanding the NestJS request lifecycle is critical because the order of execution (middleware → guards → interceptors → pipes → handler → interceptors → filters) determines where your logic should live. Put validation in the wrong layer and it either runs too early (before auth) or too late (after the handler). This lifecycle was designed after Express's flat middleware chain proved insufficient — NestJS gives each layer a specific responsibility and execution order, making large applications predictable and debuggable.

Client Request
    │
    ▼
┌─────────────────────────┐
│   Middleware             │  (Express/Fastify middleware)
└────────────┬────────────┘
             ▼
┌─────────────────────────┐
│   Guards                │  (Authorization: can this user access?)
└────────────┬────────────┘
             ▼
┌─────────────────────────┐
│   Interceptors (before) │  (Logging, timing, caching)
└────────────┬────────────┘
             ▼
┌─────────────────────────┐
│   Pipes                 │  (Validation, transformation)
└────────────┬────────────┘
             ▼
┌─────────────────────────┐
│   Route Handler         │  (Your controller method)
└────────────┬────────────┘
             ▼
┌─────────────────────────┐
│   Interceptors (after)  │  (Response mapping, timing)
└────────────┬────────────┘
             ▼
┌─────────────────────────┐
│   Exception Filters     │  (Error formatting)
└────────────┬────────────┘
             ▼
        Client Response

Step 2: Pipes — Validate & Transform Input

Pipes run before the route handler and have two jobs: validate input (reject malformed requests with 400 errors) and transform input (convert string IDs to numbers, parse dates, strip unknown fields). They were created because validation logic was being duplicated across every controller method. With class-validator decorators on DTOs and NestJS's ValidationPipe, you declare validation rules once on your DTO class and every route that uses that DTO is automatically validated — no manual checking needed.

Built-in Pipes

import {
  ParseIntPipe,
  ParseBoolPipe,
  ParseUUIDPipe,
  DefaultValuePipe,
  ValidationPipe,
} from "@nestjs/common";

@Controller("users")
export class UsersController {
  // ParseIntPipe — converts string param to number
  @Get(":id")
  getUser(@Param("id", ParseIntPipe) id: number) {
    // id is guaranteed to be a number
    return this.usersService.findById(id);
  }

  // ParseUUIDPipe — validates UUID format
  @Get(":uuid")
  getByUuid(@Param("uuid", ParseUUIDPipe) uuid: string) {
    return this.usersService.findByUuid(uuid);
  }

  // DefaultValuePipe — provide default when missing
  @Get()
  findAll(
    @Query("page", new DefaultValuePipe(1), ParseIntPipe) page: number,
    @Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number,
  ) {
    return this.usersService.paginate(page, limit);
  }
}

Validation Pipe with class-validator

npm install class-validator class-transformer
// dto/create-user.dto.ts
import { IsEmail, IsString, MinLength, IsOptional, IsEnum } from "class-validator";

export class CreateUserDto {
  @IsString()
  @MinLength(2)
  name: string;

  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;

  @IsOptional()
  @IsEnum(["user", "admin"])
  role?: string;
}
// Controller automatically validates body against DTO
@Post()
createUser(@Body() dto: CreateUserDto) {
  // dto is guaranteed valid — invalid requests get 400 Bad Request
  return this.usersService.create(dto);
}

Enable Globally

// main.ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,         // Strip properties not in DTO
    forbidNonWhitelisted: true, // Error on extra properties
    transform: true,         // Auto-transform to DTO class instances
  }),
);

Custom Pipe

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

@Injectable()
export class ParseDatePipe implements PipeTransform<string, Date> {
  transform(value: string): Date {
    const date = new Date(value);
    if (isNaN(date.getTime())) {
      throw new BadRequestException(`"${value}" is not a valid date`);
    }
    return date;
  }
}

// Usage
@Get("events")
getEvents(@Query("from", ParseDatePipe) from: Date) {
  return this.eventsService.findAfter(from);
}

Step 3: Interceptors — Transform Around Handler

Interceptors are the most versatile NestJS building block — they wrap the route handler and can transform both the request (before) and response (after). They use RxJS observables to tap into the response stream, enabling patterns like response mapping (wrapping all responses in { data, meta }), caching, logging with timing, timeout enforcement, and retry logic. The "around" pattern (aspect-oriented programming) keeps cross-cutting concerns out of your business logic entirely.

Interceptors wrap the route handler and can modify both input and output:

Logging Interceptor

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Logger,
} from "@nestjs/common";
import { Observable, tap } from "rxjs";

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger("HTTP");

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const { method, url } = request;
    const now = Date.now();

    return next.handle().pipe(
      tap(() => {
        const response = context.switchToHttp().getResponse();
        this.logger.log(
          `${method} ${url} ${response.statusCode} — ${Date.now() - now}ms`,
        );
      }),
    );
  }
}

Response Transform Interceptor

// Wrap all responses in { data: ..., timestamp: ... }
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, { data: T }> {
  intercept(context: ExecutionContext, next: CallHandler<T>) {
    return next.handle().pipe(
      map((data) => ({
        data,
        timestamp: new Date().toISOString(),
        path: context.switchToHttp().getRequest().url,
      })),
    );
  }
}

Cache Interceptor

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  private cache = new Map<string, { data: any; expiry: number }>();

  intercept(context: ExecutionContext, next: CallHandler) {
    const request = context.switchToHttp().getRequest();
    const key = request.url;

    const cached = this.cache.get(key);
    if (cached && cached.expiry > Date.now()) {
      return of(cached.data); // Return cached without hitting handler
    }

    return next.handle().pipe(
      tap((data) => {
        this.cache.set(key, { data, expiry: Date.now() + 60000 }); // 1 min cache
      }),
    );
  }
}

Timeout Interceptor

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      timeout(5000),
      catchError((err) => {
        if (err instanceof TimeoutError) {
          throw new RequestTimeoutException("Request timed out");
        }
        throw err;
      }),
    );
  }
}

Step 4: Exception Filters — Format Errors

Exception filters catch errors thrown anywhere in the request pipeline and transform them into HTTP responses. They exist because different APIs need different error formats (REST uses { message, statusCode }, GraphQL uses { errors: [...] }, internal APIs want stack traces in dev but not production). Custom filters let you standardize error responses, log errors to monitoring services, and handle different exception types differently — all without polluting your business logic with try/catch.

Custom Exception Filter

import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from "@nestjs/common";

@Catch() // Catches ALL exceptions
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger("ExceptionFilter");

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

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const message =
      exception instanceof HttpException
        ? exception.getResponse()
        : "Internal server error";

    const errorResponse = {
      statusCode: status,
      message: typeof message === "string" ? message : (message as any).message,
      timestamp: new Date().toISOString(),
      path: request.url,
      method: request.method,
    };

    // Log server errors
    if (status >= 500) {
      this.logger.error(
        `${request.method} ${request.url} ${status}`,
        exception instanceof Error ? exception.stack : undefined,
      );
    }

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

Custom Business Exceptions

// Custom exception for domain-specific errors
export class InsufficientFundsException extends HttpException {
  constructor(balance: number, required: number) {
    super(
      {
        message: "Insufficient funds",
        balance,
        required,
        deficit: required - balance,
      },
      HttpStatus.PAYMENT_REQUIRED,
    );
  }
}

// Usage
if (account.balance < amount) {
  throw new InsufficientFundsException(account.balance, amount);
}

Step 5: Applying Globally vs Locally

NestJS lets you apply pipes, interceptors, guards, and filters at three scopes: method, controller, or global. This flexibility exists because some concerns are universal (validation pipe should run everywhere), some are module-specific (logging interceptor for a specific API), and some are route-specific (file upload interceptor on one endpoint). Understanding scope helps you avoid both duplication (applying the same guard to every method) and over-application (global interceptors that break certain routes).

Controller-Level

@Controller("users")
@UseInterceptors(LoggingInterceptor)
@UseFilters(AllExceptionsFilter)
export class UsersController {}

Route-Level

@Get(":id")
@UseInterceptors(CacheInterceptor)
@UsePipes(ParseIntPipe)
getUser(@Param("id") id: number) {}

Global (app-wide)

// main.ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.useGlobalInterceptors(new LoggingInterceptor());
app.useGlobalFilters(new AllExceptionsFilter());

// OR via module (preferred — supports DI)
@Module({
  providers: [
    { provide: APP_PIPE, useClass: ValidationPipe },
    { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
    { provide: APP_FILTER, useClass: AllExceptionsFilter },
  ],
})
export class AppModule {}

Interview Questions

  1. What's the NestJS request lifecycle order?

    • Middleware → Guards → Interceptors (pre) → Pipes → Handler → Interceptors (post) → Exception Filters.
  2. What's the difference between a Pipe and an Interceptor?

    • Pipes transform/validate input before the handler runs. Interceptors wrap the handler and can transform both input and output, or add cross-cutting concerns (logging, caching, timing).
  3. When would you use a custom Exception Filter?

    • To standardize error response format across the API, add logging for server errors, or translate domain exceptions into HTTP responses.
  4. What does whitelist: true do in ValidationPipe?

    • Strips any properties from the request body that aren't defined in the DTO. Prevents mass assignment vulnerabilities.
Microservices & Communication Patterns

On this page

  • TL;DR
  • Step 1: Request Lifecycle
  • Step 2: Pipes — Validate & Transform Input
  • Built-in Pipes
  • Validation Pipe with class-validator
  • Enable Globally
  • Custom Pipe
  • Step 3: Interceptors — Transform Around Handler
  • Logging Interceptor
  • Response Transform Interceptor
  • Cache Interceptor
  • Timeout Interceptor
  • Step 4: Exception Filters — Format Errors
  • Custom Exception Filter
  • Custom Business Exceptions
  • Step 5: Applying Globally vs Locally
  • Controller-Level
  • Route-Level
  • Global (app-wide)
  • Interview Questions