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.
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:
@Get and @Post decorators from this module's first lesson, and the request's initial processing by middleware;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.
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.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:1export class CreateTributeDto {
2 @IsNotEmpty()
3 @IsString()
4 province: string;
5
6 @IsNumber()
7 @Min(1)
8 amount: number;
9
10 @IsEnum(['gold', 'silver', 'goods'])
11 type: string;
12}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(['gold', 'silver', 'goods']) admits those three kinds only. The rules add up: amount must satisfy both at once.The decorators themselves check nothing; they are only a description of the requirements. The executor is
, and its work is four steps, always in this order:ValidationPipe
BadRequestException if the data is invalid.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@Post()
2createTribute(@Body(ValidationPipe) tribute: CreateTributeDto) {
3 return this.tributeService.create(tribute);
4}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.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}
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 app.useGlobalPipes(new ValidationPipe())
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 primitive types, so amount arrives as a number rather than '500'.When a rule is specific to your domain, you write your own pipe - a class implementing the
interface with a single PipeTransform
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).The tribute wagon does not enter the camp without inspection, @name:
ParseIntPipe converts a string to an integer or throws an exception; you pass it as the second argument: @Param('id', ParseIntPipe) id: number,export class CreateTributeDto { → @IsNotEmpty() → @IsString() → the field name with its type,province with @IsString(), amount with @IsNumber() and @Min(1), type with @IsEnum(['gold', 'silver', 'goods']),ValidationPipe in four steps: raw data from the request → checking the rules from the DTO decorators → BadRequestException on failure → validated data to the handler,createTribute(@Body( → ValidationPipe) → tribute: CreateTributeDto),main.ts: app.useGlobalPipes(new ValidationPipe()) - not in package.json, not in tsconfig.json, not in .env,whitelist: true strips fields outside the DTO, transform: true yields a DTO instance with the right types,PipeTransform and the method transform(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.