In the Roman Empire, not every gate has the same rules. The trade gate lets merchants through, the military gate — legionaries, and the senate gate — only senators. In class-validator, we have a similar mechanism: validation groups.
A typical case: when creating a new legionary, all fields are required, but when updating, we want to allow sending only selected fields:
1// CREATE: everything required
2// { name: "Marcus", rank: "miles", age: 25, legio: "X" }
3
4// UPDATE: only changed fields
5// { rank: "centurio" } ← promotion onlyEach validation decorator accepts a
groups option:1import {
2 IsString,
3 IsNotEmpty,
4 IsNumber,
5 Min,
6 IsOptional,
7} from 'class-validator';
8
9export class LegionaryDto {
10 @IsString({ groups: ['create', 'update'] })
11 @IsNotEmpty({ groups: ['create'] }) // Required only when creating
12 name: string;
13
14 @IsString({ groups: ['create', 'update'] })
15 @IsNotEmpty({ groups: ['create'] })
16 rank: string;
17
18 @IsNumber({}, { groups: ['create', 'update'] })
19 @Min(16, { groups: ['create', 'update'] })
20 @IsNotEmpty({ groups: ['create'] })
21 age: number;
22
23 @IsString({ groups: ['create', 'update'] })
24 @IsNotEmpty({ groups: ['create'] })
25 legio: string;
26}For groups to work, you need to activate them in ValidationPipe:
1// Creation endpoint — activate the 'create' group
2@Post()
3@UsePipes(new ValidationPipe({ groups: ['create'] }))
4createLegionary(@Body() dto: LegionaryDto) {
5 return this.service.create(dto);
6}
7
8// Update endpoint — activate the 'update' group
9@Patch(':id')
10@UsePipes(new ValidationPipe({ groups: ['update'] }))
11updateLegionary(
12 @Param('id') id: string,
13 @Body() dto: LegionaryDto,
14) {
15 return this.service.update(id, dto);
16}A common pattern — a field that's optional only during updates:
1export class LegionaryDto {
2 @IsOptional({ groups: ['update'] })
3 @IsString({ groups: ['create', 'update'] })
4 @IsNotEmpty({ groups: ['create'] })
5 name: string;
6}During
create, the name field is required and must be a string. During update, the name field is optional, but if provided — it must be a string.In practice, many developers use separate DTO classes with inheritance instead of groups:
1// Base DTO with common decorators
2export class CreateLegionaryDto {
3 @IsString()
4 @IsNotEmpty()
5 name: string;
6
7 @IsString()
8 @IsNotEmpty()
9 rank: string;
10
11 @IsNumber()
12 @Min(16)
13 age: number;
14}
15
16// Update DTO - all fields optional
17export class UpdateLegionaryDto {
18 @IsOptional()
19 @IsString()
20 name?: string;
21
22 @IsOptional()
23 @IsString()
24 rank?: string;
25
26 @IsOptional()
27 @IsNumber()
28 @Min(16)
29 age?: number;
30}NestJS also offers a special helper
PartialType:1import { PartialType } from '@nestjs/mapped-types';
2
3export class UpdateLegionaryDto extends PartialType(CreateLegionaryDto) {}
4// Automatically creates a copy with all fields optional!NestJS offers several helpers for creating DTOs based on existing ones:
1import { PartialType, PickType, OmitType, IntersectionType } from '@nestjs/mapped-types';
2
3// All fields optional
4class UpdateDto extends PartialType(CreateDto) {}
5
6// Only selected fields
7class LoginDto extends PickType(CreateDto, ['email', 'password']) {}
8
9// Without selected fields
10class PublicDto extends OmitType(CreateDto, ['password', 'secret']) {}
11
12// Combination of two DTOs
13class FullDto extends IntersectionType(CreateDto, MetadataDto) {}Validation groups and mapped types give you flexibility in handling different scenarios. Each gate of the empire can now have its own customized rules!