InteractiveFrameworks

Pipes and Validation

Validate request bodies with a DTO class and ValidationPipe, strip unknown fields, and let ParseIntPipe turn a route parameter into a number or a 400.

What you'll learn

  • Describe a request body with a DTO class and class-validator rules, and read the messages the rules generate
  • Enable ValidationPipe once for the whole application, with whitelist stripping unknown fields
  • Convert a route parameter with ParseIntPipe and get a 400 for input that is not a number

So far every handler has trusted its input. POST /cats with { "name": 42 } stores a cat named 42, and GET /cats/abc calls findOne(NaN). A real API checks its input before any logic runs, and in Nest that is the job of a pipe: something that runs on a handler's arguments just before the handler. A pipe does one of two things. It transforms the value (a string from the URL into a number), or it validates it and throws if it is wrong. Whatever a pipe throws becomes an error response before your code runs, which is what keeps handlers free of checks.

Transforming a route parameter

Route parameters arrive as strings. ParseIntPipe turns one into a number, or rejects the request:

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

The pipe goes in the decorator, after the key, and the parameter's type becomes number because that is what arrives. GET /orders/abc never reaches the handler:

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

Nest ships ParseIntPipe, ParseFloatPipe, ParseBoolPipe, ParseUUIDPipe, ParseEnumPipe, ParseArrayPipe and DefaultValuePipe, and any of them works on @Param(), @Query() or @Body(). @Query('page', new DefaultValuePipe(1), ParseIntPipe) is the usual way to read an optional numeric query value: pipes run left to right.

Describing a body with a DTO

A DTO (data transfer object) is a class that describes what a request body must look like. It is a class rather than an interface for the reason from lesson 3: interfaces vanish at compile time, and validation runs at runtime. The class carries decorators from class-validator, one per rule:

import { IsEmail, IsIn, IsOptional, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @MinLength(8)
  password: string;

  @IsOptional()
  @IsIn(['admin', 'reader'])
  role?: string;
}

Rules stack on one property. Each failing rule produces one message, and the messages are what the client receives. The ones you will reach for most:

DecoratorRule
@IsString(), @IsInt(), @IsNumber(), @IsBoolean()the value has that type
@Length(min, max), @MinLength(n), @MaxLength(n)string length
@Min(n), @Max(n), @IsPositive()numeric range
@IsEmail(), @IsUrl(), @IsUUID(), @IsDateString()a string of that format
@IsIn([...]), @IsEnum(E)one of a set of values
@IsOptional()the other rules are skipped when the value is missing
@IsArray(), @ArrayMaxSize(n), @ValidateNested()arrays and nested objects

Turning validation on

Decorators on a DTO do nothing by themselves. ValidationPipe reads them and validates every argument whose declared type is a decorated class. Enable it once, for the whole application, with app.useGlobalPipes() in main.ts, and every @Body() typed with a DTO is checked from then on.

An invalid body is answered with a 400 and one message per failed rule, ordered by the property's position in the class:

{
  "message": ["email must be an email", "password must be longer than or equal to 8 characters"],
  "error": "Bad Request",
  "statusCode": 400
}

The messages are generated from the rule and the property name, which is why the property names in a DTO are part of the API.

Three options matter from the first day. whitelist: true removes properties the DTO does not declare, so a body with an extra isAdmin field arrives without it, which closes a whole class of mass-assignment bugs. forbidNonWhitelisted: true rejects such bodies instead of trimming them. transform: true converts primitive types ("3" to 3 where the DTO says number) and hands the handler a real instance of the DTO class rather than a plain object.

Where it sits

Pipes run last before the handler, after middleware, guards and interceptors, which you will meet in the next lessons. By the time a pipe runs, the request has been allowed through; a pipe's only question is whether the arguments are usable.

Your task

  1. Turn validation on for the whole application in main.ts, with unknown properties stripped.
  2. Give CreateCatDto its rules: name is a string of 1 to 30 characters, and age is an integer of at least 0.
  3. findOne still reads id as a string and converts it by hand. Let a pipe do it, and make bad input a 400.

The tests send a body with an unknown color field, an invalid body, and a non-numeric id. Watch which of the three each change fixes. The expected error messages are in the Endpoints tab, and they are generated from the rules, so a rule that means the same thing but says it differently will not pass.

When it fails

  • The invalid body is accepted: either ValidationPipe is not enabled, or the DTO has no decorators, or the handler's parameter is not typed with the DTO class.
  • color comes back in the response: unknown properties are not being stripped. Check the pipe's options.
  • The messages differ from the expected ones: a different rule was used. @IsNotEmpty() says "should not be empty", @Length(1, 30) says "must be longer than or equal to 1 characters". Match the meaning in the task.
  • GET /cats/abc still reaches the handler: the parameter has no pipe.

Remember

  • A pipe transforms or validates an argument before the handler runs; what it throws becomes the response.
  • A DTO is a class because validation happens at runtime.
  • ValidationPipe is global, enabled once; whitelist strips what the DTO does not declare.
  • Error messages come from the rules, so the DTO is part of the API.
Stuck? Show a hint

main.ts: app.useGlobalPipes() with a ValidationPipe whose options strip unknown properties. The DTO: name needs a string rule and a length rule with a minimum and a maximum; age needs an integer rule and a minimum. The controller: pass ParseIntPipe as the second argument of @Param() and type the parameter as number.