NestJS course Β· Module 10: Data Validation

Project - a complete validation system for the Empire

7 min read
In this lesson8

The eight lessons of this module met the sentries one at a time: the class-validator decorators, ValidationPipe, transformations, nested validation, custom validators and groups. The project is where they all stand at one gate together - and then the thing that matters most turns out to be something no single lesson dwelt on: the order in which they run.

You will build a legion management system with full CRUD and validation of every input: DTOs for the legion, the legionary and the weapon, a custom validator, response transformations and a global gate configuration.

The order that explains the rest

Before you write a line, learn the road a request travels:

  1. It arrives as a plain JSON object - no classes, no types, everything a string or a number.
  2. class-transformer turns it into an instance of the DTO class, running @Type and @Transform on the way.
  3. class-validator checks the validation decorators on that instance.
  4. Only then does the result reach the handler.

From which follows a conclusion that will save you an evening: transformation happens before validation. @Transform(({ value }) => value.trim()) cuts the spaces off first, and only then does @MinLength(2) count the characters - so " A " is rejected, though to the eye it holds five.

Step 1 - enums and the first DTO

1// enums.ts
2export enum LegionaryRank {
3  MILES = 'miles',
4  OPTIO = 'optio',
5  CENTURIO = 'centurio',
6  TRIBUNUS = 'tribunus',
7  LEGATUS = 'legatus',
8}
9
10export enum WeaponType {
11  GLADIUS = 'gladius',
12  PILUM = 'pilum',
13  SCUTUM = 'scutum',
14  PUGIO = 'pugio',
15}
16
17// weapon.dto.ts
18export class CreateWeaponDto {
19  @IsString()
20  @IsNotEmpty()
21  name: string;
22
23  @IsEnum(WeaponType)
24  type: WeaponType;
25
26  @IsNumber()
27  @Min(1)
28  @Max(100)
29  damage: number;
30}

Note that the ranks and weapon types are enums, not unions such as 'miles' | 'optio'. The reason is the very one this library exists for: TypeScript types vanish at compilation. A union leaves nothing behind that a validator could question at run time; an enum remains an ordinary object, so @IsEnum(WeaponType) has something to compare against.

The @Min and @Max on damage are not ornament. Without them a client sends damage: -5 or damage: 999999 and the database accepts both without blinking.

Step 2 - nested validation

1export class CreateLegionaryDto {
2  @IsString()
3  @IsNotEmpty()
4  @MinLength(2)
5  @MaxLength(50)
6  @Transform(({ value }) => value.trim())
7  name: string;
8
9  @IsEnum(LegionaryRank)
10  rank: LegionaryRank;
11
12  @IsNumber()
13  @Min(16)
14  @Max(65)
15  age: number;
16
17  @Matches(/^LEG-[A-Z]{2,4}-\d{4}$/, {
18    message: 'The ID must have the format LEG-XX-0000',
19  })
20  militaryId: string;
21
22  @IsArray()
23  @ArrayMinSize(1)
24  @IsString({ each: true })
25  skills: string[];
26
27  @ValidateNested()
28  @Type(() => CreateWeaponDto)
29  primaryWeapon: CreateWeaponDto;
30
31  @IsOptional()
32  @ValidateNested()
33  @Type(() => CreateWeaponDto)
34  secondaryWeapon?: CreateWeaponDto;
35}

Here lies the commonest trap in the whole module. @ValidateNested() without @Type() does not work - and it reports no error. Without @Type the transformer leaves a plain object in primaryWeapon rather than an instance of CreateWeaponDto; the validator looks inside, finds no decorators at all, and concludes that everything is in order. A weapon with negative damage passes the gate, because nobody posted a sentry there.

Two additions worth noticing. @IsString({ each: true }) checks every element of the array rather than the array as a whole - without each the rule would apply to the skills field itself. And @IsOptional() must stand before the other decorators of an optional field: it tells the validator to skip the remaining rules when the value is absent, instead of demanding it.

Step 3 - an array of nested objects

1export class CreateLegionDto {
2  @IsString()
3  @IsNotEmpty()
4  @MinLength(3)
5  name: string;
6
7  @IsEnum(LegionStatus)
8  status: LegionStatus;
9
10  @IsNumber()
11  @Min(1000)
12  @Max(6000)
13  maxSoldiers: number;
14
15  @IsArray()
16  @ArrayMinSize(1)
17  @ValidateNested({ each: true })
18  @Type(() => CreateLegionaryDto)
19  soldiers: CreateLegionaryDto[];
20}

The difference from the previous step fits in two words: { each: true }. Without it @ValidateNested() would check the array as a single object - which in practice means nothing at all. With it every legionary is validated in full, separately, weapons included, because nesting works recursively: legion β†’ legionary β†’ weapon.

It is worth being aware of the cost. A legion of six thousand soldiers, each with two weapons, means tens of thousands of checks on one request. At those sizes you add @ArrayMaxSize and consider taking soldiers through a separate endpoint - validation is cheap, but it is not free.

Step 4 - a custom validator

1@ValidatorConstraint({ async: false })
2class IsValidMilitaryIdConstraint implements ValidatorConstraintInterface {
3  validate(value: string): boolean {
4    if (!value) return false;
5    const parts = value.split('-');
6    if (parts.length !== 3) return false;
7    if (parts[0] !== 'LEG') return false;
8    const numericPart = parseInt(parts[2], 10);
9    return numericPart > 0 && numericPart <= 9999;
10  }
11
12  defaultMessage(): string {
13    return 'Invalid military identifier';
14  }
15}
16
17export function IsValidMilitaryId(options?: ValidationOptions) {
18  return function (object: object, propertyName: string) {
19    registerDecorator({
20      target: object.constructor,
21      propertyName,
22      options,
23      validator: IsValidMilitaryIdConstraint,
24    });
25  };
26}

A custom validator always has two parts: the class holding the rule and the decorator function that registers it. The class supplies a validate returning a boolean and a defaultMessage with the wording; the function lets you use the rule exactly like a built-in one, through @IsValidMilitaryId().

Why bother, when @Matches from step two does something similar? Because a regular expression checks the shape, not the sense. Here a further condition appears - that the number falls between 1 and 9999 - a rule belonging to the domain rather than the syntax. As such conditions multiply, they all have one home, and the error message can be written in plain words. The { async: false } flag says the rule does not reach for the database; for a uniqueness check you set true and validate returns a Promise.

Step 5 - the response DTO

1export class LegionaryResponseDto {
2  @Expose()
3  name: string;
4
5  @Expose()
6  rank: string;
7
8  @Expose()
9  @Transform(({ value }) => (value > 10 ? 'Veteranus' : 'Tiro'))
10  experienceLevel: string;
11
12  @Exclude()
13  secretMissionCode: string;
14
15  @Exclude()
16  salary: number;
17}

Validation watches what comes in. Here we watch what goes out - equally important, since a leak of pay and mission codes will not announce itself. @Expose() lets a field through, @Exclude() removes it, @Transform computes a derived value on the fly.

One thing is easy to forget: the decorators alone do nothing. For them to take effect the controller needs @UseInterceptors(ClassSerializerInterceptor), or you must call plainToInstance with excludeExtraneousValues: true. Without that the service returns a plain object and @Exclude() remains an annotation with no consequence - and that is exactly how data leaks that everybody believed was hidden.

Step 6 - configuring the gate

1async function bootstrap() {
2  const app = await NestFactory.create(AppModule);
3
4  app.useGlobalPipes(
5    new ValidationPipe({
6      whitelist: true,
7      forbidNonWhitelisted: true,
8      transform: true,
9      transformOptions: {
10        enableImplicitConversion: true,
11      },
12    }),
13  );
14
15  await app.listen(3000);
16}

Four options, four decisions. whitelist: true strips fields absent from the DTO. forbidNonWhitelisted: true goes further and rejects such a request with a 400 - the stricter choice, and by that token the more honest one: the client learns it sent something unexpected, instead of assuming you saved its field.

transform: true makes the handler receive an instance of the DTO class rather than a plain object - without it the @Type and @Transform of the earlier steps lie dead. enableImplicitConversion: true adds conversion of simple types: "25" from a query parameter becomes the number 25, because the field's type says number. Switch that last one on deliberately - it is convenient, but it can turn "0" into 0 and "false" into true.

What you hand in

The project is finished when it meets five conditions:

  1. CRUD for legions - endpoints for creating, reading, updating and deleting.
  2. DTOs with class-validator for every input, with nested validation and arrays.
  3. At least one custom validator registered through registerDecorator.
  4. Transformations with class-transformer on the way in (@Type, @Transform) and on the way out (@Expose, @Exclude).
  5. A global ValidationPipe with the options from step six.

Check two things at the end, the two that most often slip by: that every @ValidateNested has a @Type beside it, and that the fields marked @Exclude() really do not appear in the response. Both fail quietly - no error, no warning, until the day somebody notices.

Send the link to your repository when you are done.

Code for this lesson: src/complete-validation.ts
1// Main project - Complete Imperium Validation System
2import {
3  IsString, IsNotEmpty, IsEnum, IsNumber,
4  Min, Max, MinLength, MaxLength, IsArray,
5  ArrayMinSize, ValidateNested, IsOptional, Matches,
6  ValidatorConstraint, ValidatorConstraintInterface,
7  registerDecorator, ValidationOptions,
8} from 'class-validator';
9import { Type, Transform, Exclude, Expose } from 'class-transformer';
10
11// === ENUMS ===
12enum LegionaryRank {
13  MILES = 'miles',
14  OPTIO = 'optio',
15  CENTURIO = 'centurio',
16  LEGATUS = 'legatus',
17}
18
19enum WeaponType {
20  GLADIUS = 'gladius',
21  PILUM = 'pilum',
22  SCUTUM = 'scutum',
23}
24
25// === CUSTOM VALIDATOR ===
26// TODO: Implement IsValidMilitaryId
27@ValidatorConstraint({ async: false })
28class IsValidMilitaryIdConstraint
29  implements ValidatorConstraintInterface {
30  validate(value: string): boolean {
31    if (!value) return false;
32    return /^LEG-[A-Z]{2,4}-\d{4}$/.test(value);
33  }
34  defaultMessage(): string {
35    return 'Invalid military ID!';
36  }
37}
38
39// === WEAPON DTO ===
40class CreateWeaponDto {
41  @IsString()
42  @IsNotEmpty()
43  name: string;
44
45  @IsEnum(WeaponType)
46  type: WeaponType;
47
48  @IsNumber()
49  @Min(1)
50  @Max(100)
51  damage: number;
52}
53
54// === LEGIONARY DTO ===
55// TODO: Complete the missing decorators
56class CreateLegionaryDto {
57  @IsString()
58  @IsNotEmpty()
59  @MinLength(2)
60  @Transform(({ value }) => value.trim())
61  name: string;
62
63  @IsEnum(LegionaryRank)
64  rank: LegionaryRank;
65
66  @IsNumber()
67  @Min(16)
68  @Max(65)
69  age: number;
70
71  // TODO: Add nested validation for weapon
72  @ValidateNested()
73  @Type(() => CreateWeaponDto)
74  weapon: CreateWeaponDto;
75
76  @IsArray()
77  @ArrayMinSize(1)
78  @IsString({ each: true })
79  skills: string[];
80}
81
82// === RESPONSE DTO ===
83class LegionaryResponse {
84  @Expose()
85  name: string;
86
87  @Expose()
88  rank: string;
89
90  @Exclude()
91  salary: number;
92}
93
94console.log('Complete Imperium validation system ready!');
95

Spotted a mistake in this lesson?

Useful articles