14.2 DTO and Validation — class-validator and class-transformer
What is a DTO (Data Transfer Object)?
A DTO is a type-safe object for transferring data between layers. In NestJS, class-validator handles validation and class-transformer handles type conversion.
npm install class-validator class-transformer
Basic DTO
// dto/create-user.dto.ts
import {
IsString,
IsEmail,
IsOptional,
IsEnum,
MinLength,
MaxLength,
IsInt,
Min,
Max,
IsArray,
ArrayMaxSize,
} from 'class-validator'
import { Transform } from 'class-transformer'
export enum UserRole {
ADMIN = 'admin',
USER = 'user',
GUEST = 'guest',
}
export class CreateUserDto {
@IsString()
@MinLength(2, { message: 'Name must be at least 2 characters.' })
@MaxLength(50, { message: 'Name must be at most 50 characters.' })
name: string
@IsEmail({}, { message: 'Please enter a valid email address.' })
email: string
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters.' })
password: string
@IsOptional()
@IsEnum(UserRole, { message: 'Please select a valid role.' })
role?: UserRole
@IsOptional()
@IsInt()
@Min(0)
@Max(150)
@Transform(({ value }) => parseInt(value)) // Convert string to number
age?: number
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(10)
tags?: string[]
}
Update DTO (Using PartialType)
// dto/update-user.dto.ts
import { PartialType, OmitType, PickType } from '@nestjs/mapped-types'
import { CreateUserDto } from './create-user.dto'
// Make all fields optional
export class UpdateUserDto extends PartialType(CreateUserDto) {}
// Exclude specific fields
export class UpdateProfileDto extends OmitType(CreateUserDto, ['password', 'email'] as const) {}
// Select specific fields only
export class UpdatePasswordDto extends PickType(CreateUserDto, ['password'] as const) {
@IsString()
@MinLength(8)
currentPassword: string
}
Complex DTO Patterns
Nested DTO
// dto/address.dto.ts
import { IsString, IsOptional, Length } from 'class-validator'
export class AddressDto {
@IsString()
street: string
@IsString()
city: string
@IsString()
@Length(2, 2)
country: string
@IsOptional()
@IsString()
zipCode?: string
}
// dto/create-user.dto.ts
import { IsString, IsEmail, ValidateNested } from 'class-validator'
import { Type } from 'class-transformer'
import { AddressDto } from './address.dto'
export class CreateUserDto {
@IsString()
name: string
@IsEmail()
email: string
@ValidateNested() // Validate nested object
@Type(() => AddressDto) // Type conversion
address: AddressDto
}
Array DTO
import { IsArray, ValidateNested, ArrayMinSize } from 'class-validator'
import { Type } from 'class-transformer'
export class CreateUsersDto {
@IsArray()
@ValidateNested({ each: true }) // Validate each array element
@ArrayMinSize(1)
@Type(() => CreateUserDto)
users: CreateUserDto[]
}
Custom Validation Decorator
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator'
// Custom decorator: password match check
export function IsPasswordMatch(
property: string,
validationOptions?: ValidationOptions
) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'IsPasswordMatch',
target: object.constructor,
propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: unknown, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints
const relatedValue = (args.object as Record<string, unknown>)[relatedPropertyName]
return value === relatedValue
},
defaultMessage() {
return 'Passwords do not match.'
},
},
})
}
}
// Usage
export class RegisterDto {
@IsString()
@MinLength(8)
password: string
@IsString()
@IsPasswordMatch('password')
confirmPassword: string
}
ValidationPipe Configuration
// main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Auto-strip fields not in DTO
forbidNonWhitelisted: true, // 400 error if extra fields present
transform: true, // Auto type conversion (@Transform runs)
transformOptions: {
enableImplicitConversion: true, // Implicit string → number conversion
},
disableErrorMessages: false, // Set true in production
exceptionFactory: (errors) => {
// Custom error format
const messages = errors.map(error =>
Object.values(error.constraints ?? {}).join(', ')
)
return new BadRequestException({ errors: messages })
},
})
)
class-transformer Usage
import { Expose, Exclude, Transform, Type } from 'class-transformer'
// Response DTO — exclude sensitive fields
export class UserResponseDto {
@Expose()
id: string
@Expose()
name: string
@Expose()
email: string
@Exclude() // Exclude from response
password: string
@Expose()
@Transform(({ value }) => value.toISOString())
createdAt: Date
@Expose()
@Type(() => AddressDto)
address?: AddressDto
}
// Transform in service
import { plainToInstance } from 'class-transformer'
function toDto(user: User): UserResponseDto {
return plainToInstance(UserResponseDto, user, {
excludeExtraneousValues: true, // Exclude fields without @Expose()
})
}
Pro Tips
1. Swagger Integration with DTO
npm install @nestjs/swagger
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
export class CreateUserDto {
@ApiProperty({ description: 'User name', example: 'Alice' })
@IsString()
name: string
@ApiProperty({ description: 'Email', example: 'user@example.com' })
@IsEmail()
email: string
@ApiPropertyOptional({ description: 'Role', enum: UserRole })
@IsOptional()
@IsEnum(UserRole)
role?: UserRole
}
2. Query Parameter DTO
import { IsOptional, IsInt, Min, IsString } from 'class-validator'
import { Type } from 'class-transformer'
export class PaginationDto {
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number = 1
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
limit?: number = 10
@IsOptional()
@IsString()
search?: string
}