Skip to main content
Advertisement

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
}
Advertisement