NestJS course Β· Module 2: Routing and Request Lifecycle
Pipes - inspection at the camp gate
In this lesson7
A wagon of tribute rolls up to the camp gate. The sentry does not ask who sent it - that was settled earlier, at the outer post. He asks something else: whether the crates hold what the manifest says, whether the quantities add up, whether the "twelve" written on the docket can be counted at all. Only then does the wagon roll in, and the quartermaster receives a counted, verified load - not a heap of sacks to sort through.
In this module you have already met three mechanisms standing in a request's path: middleware, guards and interceptors. Pipes are the fourth and last mechanism of the NestJS request cycle - and they do something none of the others does.
Two jobs, no more
Pipes have two uses: validation and transformation of input data. It is worth marking them off from their neighbours right away, because in the request cycle they stand side by side:
- routing is handled by the
@Getand@Postdecorators from this module's first lesson, and the request's initial processing by middleware; - authorisation - the question of who may enter - is settled by guards;
- logging, caching and reshaping the response is the work of interceptors.
A pipe touches neither the request as a whole nor the response. It receives one argument of a controller method and either lets it through - often transformed - or throws an exception. It runs after the guards, right before the method is entered.
ParseIntPipe - built-in transformation
A parameter pulled from a URL is always a string. The request /tributes/12 gives you '12', not 12:
1@Controller('tributes')
2export class TributeController {
3 @Get(':id')
4 findById(@Param('id', ParseIntPipe) id: number) {
5 return this.tributeService.findById(id);
6 }
7}The pipe goes as the second argument to @Param: the first is the parameter's name in the route, the second is the pipe meant to handle it. ParseIntPipe converts a string to an integer or throws an exception - and that is all. It does not parse JSON into an object, it does not convert a boolean to a string, it does not turn a date into a timestamp.
The exception matters here as much as the conversion. After a call to /tributes/abc the findById method does not run at all - the client gets a 400 Bad Request, and inside the method id is guaranteed to be a number. That is why the id: number annotation is not wishful thinking.
Its siblings work the same way: ParseBoolPipe for 'true' and 'false', ParseUUIDPipe for UUID identifiers.
DTO - rules written beside the fields
For a larger load, single pipes are not enough. Then we describe the expected shape of the data in a DTO (Data Transfer Object) - a class whose fields carry decorators from the class-validator library. The allowed kinds of tribute go first into the TributeType enum:
1export enum TributeType {
2 GOLD = 'gold',
3 SILVER = 'silver',
4 GOODS = 'goods',
5}
6
7export class CreateTributeDto {
8 @IsNotEmpty()
9 @IsString()
10 province: string;
11
12 @IsNumber()
13 @Min(1)
14 amount: number;
15
16 @IsEnum(TributeType)
17 type: TributeType;
18}The written order is fixed: first the class opening (export class CreateTributeDto {), then the decorators - each on its own line - and last of all the field they apply to. A decorator always sits above its field, never beside it, and every further field of the class is written the same way.
Each decorator is one rule. @IsNotEmpty() rejects an empty value, @IsString() guards the type, @IsNumber() demands a number, @Min(1) a number of at least one - because a tribute of "zero pieces of gold" is no tribute - and @IsEnum(TributeType) admits the enum's values only (on an error the message lists them all). The rules add up: amount must satisfy both at once.
ValidationPipe - four steps
The decorators themselves check nothing; they are only a description of the requirements. The executor is ValidationPipe, and its work is four steps, always in this order:
- Receiving raw data from the request.
- Checking validation rules from DTO decorators.
- Throwing
BadRequestExceptionif the data is invalid. - Returning validated data to the handler.
Step three breaks the cycle - the handler never sees bad data because it does not run at all, and the client gets a 400 listing the fields that failed. Step four is why the whole business is worth it: the method receives an object known to satisfy every rule.
Attaching it to a method looks like this:
1@Controller('tributes')
2export class TributeController {
3 @Post()
4 createTribute(@Body(ValidationPipe) tribute: CreateTributeDto) {
5 return this.tributeService.create(tribute);
6 }
7}Read it from the left. createTribute(@Body( opens a parameter taken from the request body, ValidationPipe) closes @Body by naming the pipe that is to check it, and tribute: CreateTributeDto) names the parameter and supplies the class holding the rules. Without that last part the pipe would have nothing to check against - it is the type that tells it which decorators to look for.
Global registration
Adding ValidationPipe to every @Body grows tedious quickly, and one omission means one endpoint with no checks. So we register it once, globally, in main.ts:
1async function bootstrap() {
2 const app = await NestFactory.create(AppModule);
3
4 app.useGlobalPipes(
5 new ValidationPipe({
6 whitelist: true,
7 transform: true,
8 }),
9 );
10
11 await app.listen(3000);
12}app.useGlobalPipes(new ValidationPipe()) covers every endpoint of the application at once. Note that this is code executed at startup, not a configuration entry - which is why you will not find it in package.json (a list of dependencies), nor in tsconfig.json (compiler settings), nor in .env (environment variables). None of those files runs anything.
Two options are worth switching on straight away. whitelist: true strips fields not described in the DTO - a client may send isAdmin: true, but it will never reach the service. transform: true turns a plain object from JSON into an instance of the DTO class, and converts route and query parameters with a primitive type from text to that type: with @Param('id') id: number you get 12, not '12'. It does not convert DTO fields - if a client sends "amount": "500", @IsNumber() rejects the request with a 400. If you want that conversion too, add transformOptions: { enableImplicitConversion: true }.
A pipe of your own
When a rule is specific to your domain, you write your own pipe - a class implementing the PipeTransform interface with a single transform method:
1@Injectable()
2export class TributeSealPipe implements PipeTransform {
3 transform(value: string, metadata: ArgumentMetadata) {
4 const seal = value.trim().toUpperCase();
5
6 if (seal.length !== 8 || !seal.startsWith('SPQR')) {
7 throw new BadRequestException(
8 `Seal ${value} is not a seal of the empire`,
9 );
10 }
11
12 return seal;
13 }
14}The method receives two arguments: value - the value to be checked - and metadata of type ArgumentMetadata, which holds among other things type ('body', 'query' or 'param') and the parameter's expected type. The contract is simple: return a value or throw an exception. The returned value - here trimmed and raised to upper case - reaches the controller method in place of the original.
That is the same pair of jobs as at the outset: validation (checking the length and the prefix) and transformation (trim with toUpperCase). You use it exactly as you use a built-in one: @Param('seal', TributeSealPipe).
Summary
The tribute wagon does not enter the camp without inspection:
- pipes serve for validation and transformation of input data - not for routing and middleware, not for authorisation and logging, not for caching and compression,
- a pipe receives one argument of a method and runs after the guards, right before the handler,
ParseIntPipeconverts a string to an integer or throws an exception; you pass it as the second argument:@Param('id', ParseIntPipe) id: number,- a DTO field is written in the order:
export class CreateTributeDto {β the decorators (@IsNotEmpty(),@IsString(); their order among themselves does not change the validation result) β the field name with its type, - the tribute's rules:
provincewith@IsString(),amountwith@IsNumber()and@Min(1),typewith@IsEnum(TributeType), ValidationPipein four steps: raw data from the request β checking the rules from the DTO decorators βBadRequestExceptionon failure β validated data to the handler,- in the controller method:
createTribute(@Body(βValidationPipe)βtribute: CreateTributeDto), - globally in
main.ts:app.useGlobalPipes(new ValidationPipe())- not inpackage.json, not intsconfig.json, not in.env, whitelist: truestrips fields outside the DTO,transform: trueyields a DTO instance and converts primitive route and query parameters,- your own pipe implements
PipeTransformand the methodtransform(value, metadata: ArgumentMetadata)- returning a value or throwing an exception.
In the next lesson we shall start stamping seals of our own - writing decorators tailored to the empire. For now remember: a pipe is the last gate before your code, and everything that passes it is already what you expect.
Code for this lesson: src/pipes.ts
1// Pipes in NestJS - Craftsmen and Quality Controllers
2import {
3 PipeTransform, Injectable, ArgumentMetadata,
4 BadRequestException, ParseIntPipe, ParseBoolPipe,
5 DefaultValuePipe, ValidationPipe,
6} from '@nestjs/common';
7
8console.log("Pipes - data transformation and validation!");
9
10// ===========================================
11// 1. Built-in Pipes
12// ===========================================
13
14// ParseIntPipe - converts string to number
15// @Get(':id')
16// findOne(@Param('id', ParseIntPipe) id: number) { ... }
17
18// ParseBoolPipe - converts string to boolean
19// @Get()
20// findAll(@Query('active', ParseBoolPipe) active: boolean) { ... }
21
22// DefaultValuePipe - default value
23// @Get()
24// findAll(@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number)
25
26// ===========================================
27// 2. Custom Pipe - validation
28// ===========================================
29
30@Injectable()
31export class RomanRankValidationPipe implements PipeTransform {
32 private readonly validRanks = [
33 'Miles', 'Optio', 'Centurio', 'Tribunus', 'Legatus',
34 ];
35
36 transform(value: any, metadata: ArgumentMetadata) {
37 if (!this.validRanks.includes(value)) {
38 throw new BadRequestException(
39 'Rank "' + value + '" does not exist in the empire! '
40 + 'Available ranks: ' + this.validRanks.join(', ')
41 );
42 }
43 return value;
44 }
45}
46
47// Usage: @Param('rank', RomanRankValidationPipe) rank: string
48
49// ===========================================
50// 3. Custom Pipe - transformation
51// ===========================================
52
53@Injectable()
54export class TrimPipe implements PipeTransform {
55 transform(value: any, metadata: ArgumentMetadata) {
56 if (typeof value === 'string') {
57 return value.trim();
58 }
59 if (typeof value === 'object' && value !== null) {
60 for (const key of Object.keys(value)) {
61 if (typeof value[key] === 'string') {
62 value[key] = value[key].trim();
63 }
64 }
65 }
66 return value;
67 }
68}
69
70// ===========================================
71// 4. ValidationPipe z class-validator
72// ===========================================
73
74// class CreateLegionDto {
75// @IsString() @IsNotEmpty()
76// name: string;
77//
78// @IsNumber() @Min(1000) @Max(6000)
79// soldiers: number;
80//
81// @IsEnum(LegionType)
82// type: LegionType;
83// }
84
85// Globally in main.ts:
86// app.useGlobalPipes(new ValidationPipe({
87// whitelist: true, // Remove unknown fields
88// forbidNonWhitelisted: true, // Error on unknown fields
89// transform: true, // Automatic type transformation
90// }));
91
92console.log("\n=== PIPES SUMMARY ===");
93console.log("ParseIntPipe, ParseBoolPipe - built-in transformers");
94console.log("DefaultValuePipe - default values");
95console.log("PipeTransform - custom pipe interface");
96console.log("ValidationPipe + class-validator - DTO validation");
97console.log("whitelist: true - removes unknown fields from body");
98Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What are the two main uses of Pipes in NestJS?
2. What does ParseIntPipe do in NestJS?
3. How do you register ValidationPipe globally in a NestJS application?
Hands-on tasks in the game
- Code editor
Write an endpoint @Get(':id') that uses ParseIntPipe to validate the id parameter
- Vertical ordering
Order the steps of data validation by ValidationPipe:
- Click in order
Arrange the elements of a DTO field definition with validation in the correct order:
- Code editor
Write a CreateTributeDto with province (@IsString), amount (@IsNumber, @Min(1)), and type (@IsEnum) fields
- Horizontal ordering
Arrange the elements of using ValidationPipe in a controller method: