RizTech Academy logo
RizTech Academy
NestJS FundamentalsLesson 5 of 630 min

Pipes, filters and interceptors

Three things run around your handler rather than inside it: pipes transform and validate what goes in, filters turn thrown exceptions into responses, and interceptors wrap the whole thing. Together they remove most of the repetition from the Express example in the first lesson.

Pipes

A pipe runs on an argument before the handler receives it. It either returns a transformed value or throws.

Built-in pipes

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

@Get(":id")
findOne(@Param("id", ParseIntPipe) id: number) {
  return this.products.findOne(id);       // a real number
}

@Get()
findAll(@Query("page", new DefaultValuePipe(1), ParseIntPipe) page: number) {
  return this.products.findAll({ page });
}

This is the fix for "every URL value is a string". ParseIntPipe converts, and throws a 400 if it cannot:

{ "statusCode": 400, "message": "Validation failed (numeric string is expected)" }

Note the order: DefaultValuePipe first, so a missing page becomes 1 before ParseIntPipe sees it. Reversed, ParseIntPipe would receive undefined and fail.

Pipe Does
ParseIntPipe string → number, 400 if not numeric
ParseFloatPipe string → float
ParseBoolPipe "true"/"false" → boolean
ParseUUIDPipe validates a UUID
ParseArrayPipe comma-separated → array
DefaultValuePipe substitutes a default for undefined
ValidationPipe validates a DTO — the important one

ValidationPipe

Turn it on globally once:

// apps/api/src/main.ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }),
);

Those three options matter:

whitelist: true strips properties not declared on the DTO. Without it, a client can send { name: "x", isAdmin: true } and that extra field flows into your code — and into a database update if you spread the DTO. This is a real privilege-escalation route.

forbidNonWhitelisted: true rejects instead of stripping, which surfaces mistakes rather than silently ignoring them.

transform: true turns the plain body into an instance of your DTO class.

Do not enable implicit conversion

You will see this recommended widely:

transformOptions: { enableImplicitConversion: true }

It converts query strings to the declared TypeScript types, so a page: number arrives as a number without you asking. Convenient, and it silently overrides your explicit transforms.

The case that bites:

@Transform(({ value }) => value === true || value === "true")
@IsBoolean()
inStock?: boolean;

With implicit conversion on, ?inStock=false arrives as true. The implicit converter runs its own boolean coercion and wins, so "false" — a non-empty string — becomes true. Your transform is ignored and nothing warns you.

The result is a filter that does the opposite of what was asked, with no error anywhere. Verify it yourself:

plainToInstance(Dto, { inStock: "false" }, { enableImplicitConversion: true });
// => { inStock: true }

plainToInstance(Dto, { inStock: "false" }, { enableImplicitConversion: false });
// => { inStock: false }

Convert explicitly instead. @Type(() => Number) for numbers, @Transform for booleans. Three extra decorators across a DTO, and the behaviour is the one you wrote.

DTOs and the validation decorators are the next module's first lesson; the pipe is what enforces them.

A custom pipe

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

@Injectable()
export class ParseSlugPipe implements PipeTransform<string, string> {
  transform(value: string): string {
    const slug = value.trim().toLowerCase();
    if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
      throw new BadRequestException(`"${value}" is not a valid slug`);
    }
    return slug;
  }
}
@Get(":slug")
findOne(@Param("slug", ParseSlugPipe) slug: string) { ... }

Now every handler taking a slug gets a validated, normalised one, and no handler repeats the check.

Exception filters

Built-in exceptions already produce sensible responses. A filter takes over when you want a consistent shape across the whole API:

// apps/api/src/common/filters/http-exception.filter.ts
import {
  ArgumentsHost, Catch, ExceptionFilter, HttpException,
  HttpStatus, Logger,
} from "@nestjs/common";
import { Request, 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<Response>();
    const request = ctx.getRequest<Request>();

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

    const message =
      exception instanceof HttpException
        ? exception.getResponse()
        : "Something went wrong";

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

    response.status(status).json({
      statusCode: status,
      path: request.url,
      timestamp: new Date().toISOString(),
      ...(typeof message === "object" ? message : { message }),
    });
  }
}
app.useGlobalFilters(new AllExceptionsFilter());

The important behaviour: a 500 logs the stack and returns a generic message. Anything else returns its own message. An unexpected error must never send a database error or a file path to a client, and doing it here means no individual handler has to remember.

@Catch() with no argument catches everything. @Catch(PrismaClientKnownRequestError) would catch one type — useful for turning a unique-constraint violation into a 409.

Interceptors

An interceptor wraps the handler, so it can act before and after.

Logging:

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

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

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

Stripping sensitive fields, which is the structural fix for the password-hash problem from the controllers lesson:

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

With class-transformer, fields marked @Exclude() never leave the API — so forgetting a select stops being a security incident.

next.handle() returns an RxJS Observable, which is why .pipe(tap(...)) appears. You do not need to learn RxJS to use interceptors; tap for side effects and map for transforming the result cover almost everything.

The order, and why it matters

request → middleware → guard → interceptor (before) → pipe → handler
                                                                 ↓
response ← filter ← interceptor (after) ← ──────────────────────┘

Consequences worth knowing:

Guards run before pipes. An unauthenticated request with an invalid body gets 401, not 400. Correct — you should not tell an anonymous caller what is wrong with their payload.

Pipes run after interceptors start. A logging interceptor sees the request before validation, so it logs requests that later fail validation. Usually what you want.

Filters run last. Anything thrown anywhere — guard, pipe, handler, service — ends up there.

Wiring them up

// apps/api/src/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.setGlobalPrefix("api");
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
      // No enableImplicitConversion — it overrides explicit @Transform.
    }),
  );
  app.useGlobalFilters(new AllExceptionsFilter());
  app.useGlobalInterceptors(new LoggingInterceptor());

  await app.listen(process.env.PORT ?? 3001);
}

Global versions registered here cannot inject dependencies. When one needs a service, register it as a provider instead:

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

APP_FILTER, APP_PIPE, APP_GUARD and APP_INTERCEPTOR come from @nestjs/core and are the form to use when injection is needed.

Check your work

Why DefaultValuePipe before ParseIntPipe: pipes run in order, so the default must be substituted before the parse, or the parse receives undefined.

What whitelist: true prevents: extra properties reaching your code — including something like isAdmin: true flowing into a database update.

Why not to enable implicit conversion: it overrides explicit @Transform decorators, so ?inStock=false arrives as true and the filter does the opposite of what was asked, silently.

Difference between whitelist and forbidNonWhitelisted: the first strips unknown fields silently, the second rejects the request with a 400.

What a global exception filter must do with a 500: log the stack and return a generic message, so no internal detail reaches the client.

Why guards run before pipes: an unauthenticated request should be rejected before its body is inspected, so it gets 401 rather than 400.

Where a thrown exception ends up: the exception filter, wherever it was thrown from.

When to use APP_FILTER instead of useGlobalFilters: when the filter needs dependency injection.

Practice

  1. Add ParseIntPipe to a numeric route parameter. Pass abc and read the 400.
  2. Add DefaultValuePipe for page. Reverse the order and read the failure.
  3. Enable the global ValidationPipe with whitelist, forbidNonWhitelisted and transform.
  4. Send an extra field not on your DTO. Confirm forbidNonWhitelisted rejects it, then set it to false and confirm the field is silently stripped.
  5. Write ParseSlugPipe and use it. Pass Not A Slug! and read the message.
  6. Add the global exception filter. Throw a NotFoundException and a plain Error and compare the responses and your logs.
  7. Add the logging interceptor. Confirm timings appear for every request.
  8. Add enableImplicitConversion: true and a boolean DTO field with a @Transform. Send ?flag=false and confirm it arrives as true. Remove the option and confirm it arrives as false.
  9. Send an unauthenticated request with an invalid body to a guarded route. Confirm you get 401, not 400, and explain why.
  10. Convert the global filter to APP_FILTER and inject a service into it.

Next: configuration and secrets.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship