We use cookies to enhance your experience on the site
CodeWorlds

Pipes - inspection at the camp gate

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
    @Get
    and
    @Post
    decorators 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:

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.

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:

  1. Receiving raw data from the request.
  2. Checking validation rules from DTO decorators.
  3. Throwing
    BadRequestException
    if the data is invalid.
  4. 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@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.

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 primitive types, so
amount
arrives as a
number
rather than
'500'
.

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, @name:

  • 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,
  • ParseIntPipe
    converts 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 {
    @IsNotEmpty()
    @IsString()
    → the field name with its type,
  • the tribute's rules:
    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,
  • in the controller method:
    createTribute(@Body(
    ValidationPipe)
    tribute: CreateTributeDto)
    ,
  • globally in
    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,
  • your own pipe implements
    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.

Go to CodeWorlds