ValidationTechniques · NestJS

Enforce the API's contract on every route: a global ValidationPipe that strips and refuses unknown properties and transforms payloads, an update DTO derived from the create DTO, and validated arrays in the body and the query string.

What you will learn

Read the theory for Validation

All Techniques lessons

All NestJS courses

loading types…

What you'll learn

  • Register one ValidationPipe for the application and choose whitelist, forbidNonWhitelisted and transform deliberately
  • Derive an update DTO from a create DTO with PartialType so validators are written once
  • Validate a body array and parse a comma-separated query list with ParseArrayPipe

Validation

Lesson 5 of Basics put a ValidationPipe on one route and watched class-validator turn a bad body into a 400. That is the mechanism. This lesson is about running it everywhere, and about the three things a real API needs from it beyond "is this field a string": dropping properties a client should not send, validating an update that carries only some fields, and validating things that are not a single object, such as a list in the body or a comma-separated list in the query string.

One pipe for the whole application

Validation belongs to every route, so it goes on the application rather than on a handler:

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

From here on any @Body(), @Param() or @Query() whose declared type is a class decorated with class-validator is validated before the handler runs, and a failure is a 400 with every message:

{ "statusCode": 400, "message": ["email must be an email"], "error": "Bad Request" }

disableErrorMessages: true sends only the status to the client, which production APIs sometimes prefer; the messages are for development.

Stripping and refusing unknown properties

By default a body may carry any property; the DTO only checks the ones it declares, and the rest reach your handler. Two options change that:

new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true });

whitelist removes every property that has no validation decorator, so a client cannot smuggle isAdmin: true into a DTO that never mentioned it. forbidNonWhitelisted goes further and rejects the request instead of silently cleaning it, with a message naming the property: property isAdmin should not exist. Refusing is the better default for an API with a contract; stripping is the kinder one for forms.

Transforming the payload

A request body is JSON, a route parameter is a string. transform: true makes the pipe return an instance of the DTO class rather than the raw object, and, for primitives, converts to the declared type:

new ValidationPipe({ transform: true });

@Get(':id')
findOne(@Param('id') id: number) {
  console.log(typeof id); // number
}

Without transform, that id is "3" whatever the annotation says, and cats.find((c) => c.id === id) never matches. The explicit pipes still exist for when you want the conversion on one parameter only: @Param('id', ParseIntPipe) converts and rejects a non-number with Validation failed (numeric string is expected); ParseBoolPipe, ParseUUIDPipe and ParseEnumPipe do the same for their types.

Mapped types: an update is a partial create

UpdateCatDto has the same fields as CreateCatDto, each optional. Copying the class and adding ? to everything is the obvious way and the wrong one: two copies of every validator, drifting apart. @nestjs/mapped-types builds one class from another, validators included:

import { PartialType } from '@nestjs/mapped-types';

export class UpdateCatDto extends PartialType(CreateCatDto) {}

PartialType makes every property optional and keeps its decorators, so a PATCH body with only age passes, and one with age: "four" still fails the @IsInt() it inherited. PickType(CreateCatDto, ['name'] as const) keeps some properties, OmitType drops some, IntersectionType merges two classes, and they compose: PartialType(OmitType(CreateCatDto, ['name'] as const)).

Arrays

@Body() dtos: CreateCatDto[] validates nothing, because a TypeScript array type leaves no class behind at runtime for the pipe to instantiate. ParseArrayPipe takes the item class explicitly:

@Post('bulk')
createMany(@Body(new ParseArrayPipe({ items: CreateCatDto })) dtos: CreateCatDto[]) {}

Each item is validated with the class's decorators and a body that is not an array at all is Validation failed (parsable array expected). The same pipe parses a query string list, and there a bad element is named by its index, [1] item must be a number:

@Get()
findByIds(@Query('ids', new ParseArrayPipe({ items: Number, separator: ',' })) ids: number[]) {}

GET /?ids=1,2,3 arrives as three numbers. optional: true lets the parameter be absent.

Your task

The cats API gets a contract it enforces on every route.

  1. In main.ts, register one ValidationPipe for the application that strips undeclared properties, refuses a request that sends one, and transforms payloads to their declared types.
  2. Derive UpdateCatDto from CreateCatDto with every property optional and every validator kept.
  3. POST /cats/bulk takes an array of CreateCatDto and validates each item; GET /cats?ids=1,3 takes a comma-separated list of numbers.
  4. With transformation on, GET /cats/:id and PATCH /cats/:id can declare id as a number and use it directly.

Run after step 1 only and send PATCH /cats/1 with { "age": "four" }: the starter's hand-written UpdateCatDto has no decorators, so whitelist strips everything and the update does nothing, quietly. That is what PartialType fixes.

When it fails

  • A body with a typo in a property name is accepted and the cat is created without it: whitelist alone strips it. Add forbidNonWhitelisted to get property colour should not exist.
  • PATCH with valid fields answers 400 property age should not exist: UpdateCatDto declares the property without a validator, so the whitelist treats it as unknown. Derive it with PartialType.
  • GET /cats/3 answers 404 for a cat that exists: the parameter is a string and the comparison is strict. Turn transform on, or use ParseIntPipe.
  • POST /cats/bulk accepts [{ "name": "Nala" }]: the array type is erased; only ParseArrayPipe({ items }) validates the items.

Remember

  • app.useGlobalPipes(new ValidationPipe({ ... })) validates every route; whitelist strips, forbidNonWhitelisted refuses, transform converts.
  • PartialType, PickType, OmitType and IntersectionType derive DTOs and keep the validators.
  • ParseArrayPipe({ items }) validates each element of a body array; with separator, it parses a query-string list.
  • ParseIntPipe and friends convert one parameter explicitly; transform: true converts by declared type.
Stuck? Show a hint

main.ts: app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })). update-cat.dto.ts: extend PartialType(CreateCatDto) from @nestjs/mapped-types. Controller: @Body(new ParseArrayPipe({ items: CreateCatDto })) for the bulk body, @Query('ids', new ParseArrayPipe({ items: Number, separator: ',', optional: true })) for the list, and with transform on, @Param('id') id: number arrives as a number.