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.
Before you write a line, learn the road a request travels:
class-transformer turns it into an instance of the DTO class, running @Type and @Transform on the way.class-validator checks the validation decorators on that instance.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.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.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.
without @ValidateNested()
does not work - and it reports no error. Without @Type()
@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.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:
. Without it { each: true }
@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.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.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.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.The project is finished when it meets five conditions:
class-validator for every input, with nested validation and arrays.registerDecorator.class-transformer on the way in (@Type, @Transform) and on the way out (@Expose, @Exclude).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, @name - no error, no warning, until the day somebody notices.Send the link to your repository when you are done.