We use cookies to enhance your experience on the site
CodeWorlds

Project - a complete validation system for the Empire

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, @name - no error, no warning, until the day somebody notices.

Send the link to your repository when you are done.

Go to CodeWorlds