TypeScript delivers a fantastic developer experience: IDE hints, type inference, and compile-time errors. But there's a blind spot that's easy to overlook: TypeScript types don't exist at runtime.
You declare name: string, but if a client sends name: 123, TypeScript won't stop it. Your code will silently treat a number as a string until something breaks downstream.
That's the problem class-validator solves: actually verifying the shape and content of data when your application is running.
Two Layers of Protection: Compile-Time vs Runtime
Client request ──► [Runtime validation] ──► [TypeScript type system] ──► Business logic
class-validator Compile-time type checking
(blocks invalid data) (IDE hints + type inference)
These two layers complement rather than replace each other. TypeScript guarantees type safety within your own code; class-validator guarantees that external input matches expectations.
Core Concept: DTOs
A DTO (Data Transfer Object) is a class that describes exactly what data an endpoint accepts or returns. In NestJS, DTOs are the natural home for class-validator decorators:
// create-user.dto.ts
import { IsString, IsEmail, IsInt, Min, Max, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(2)
@MaxLength(50)
name: string;
@IsEmail()
email: string;
@IsInt()
@Min(18)
@Max(120)
age: number;
@IsOptional()
@IsString()
department?: string;
}
Decorators are documentation: reading this DTO tells you everything about the endpoint's contract — no separate comments needed.
Enabling the Validation Pipe in NestJS
NestJS connects class-validator to the request lifecycle via ValidationPipe. We recommend enabling it globally:
// main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // strip undeclared fields automatically
forbidNonWhitelisted: true, // throw an error if unknown fields are present
transform: true, // auto-cast raw input to DTO class instances
}),
);
Three options worth understanding:
whitelist: true: extra fields sent by the client are silently stripped, preventing accidental database writesforbidNonWhitelisted: true: if you want to know when clients are probing your API with unexpected fields, enable thistransform: true: URL parameters arrive as strings; this option auto-converts them to the types declared in the DTO
Quick Reference: Common Decorators
// Strings
@IsString()
@MinLength(2)
@MaxLength(100)
@Matches(/^[a-z]+$/)
// Numbers
@IsInt()
@IsNumber()
@Min(0)
@Max(100)
// Boolean / Enum
@IsBoolean()
@IsEnum(UserRole)
// Formats
@IsEmail()
@IsUrl()
@IsDate()
@IsISO8601()
// Presence
@IsOptional() // field may be absent (skips other validators if missing)
@IsNotEmpty() // cannot be empty string / null / undefined
// Arrays
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(10)
Validating Nested Objects
When a DTO contains nested objects, combine @ValidateNested() with @Type():
import { Type } from 'class-transformer';
export class AddressDto {
@IsString()
city: string;
@IsString()
street: string;
}
export class CreateCompanyDto {
@IsString()
name: string;
@ValidateNested()
@Type(() => AddressDto) // class-transformer instantiates the nested class
address: AddressDto;
}
@Type() comes from class-transformer. It tells the pipe to instantiate the raw JSON object as the correct class before class-validator tries to inspect it.
Custom Validators
When built-in decorators aren't enough, you can write your own:
import { registerDecorator, ValidationOptions } from 'class-validator';
export function IsChinesePhone(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isChinesePhone',
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: any) {
return /^1[3-9]\d{9}$/.test(value);
},
defaultMessage() {
return 'Please enter a valid Chinese mobile number';
},
},
});
};
}
// Usage
export class ContactDto {
@IsChinesePhone()
phone: string;
}
Custom validators also support async logic — for example, checking whether a value already exists in the database — by returning Promise<boolean> from validate.
Error Response Format
When validation fails, NestJS returns a structured error response out of the box:
{
"statusCode": 400,
"message": [
"name must be longer than or equal to 2 characters",
"email must be an email"
],
"error": "Bad Request"
}
The format can be customized via exceptionFactory — for instance, grouping errors by field name for easier frontend handling.
Separating Create and Update DTOs
Create and update operations often have different field requirements: all fields are required on create, but mostly optional on update. Use inheritance with PartialType to avoid duplication:
import { PartialType } from '@nestjs/mapped-types';
export class CreateUserDto {
@IsString()
name: string;
@IsEmail()
email: string;
}
// All fields in UpdateUserDto become optional automatically
export class UpdateUserDto extends PartialType(CreateUserDto) {}
PartialType is a NestJS utility that wraps every field in the parent class with @IsOptional() while preserving the original validation rules. One source of truth, two behaviors.
Summary
class-validator extends TypeScript's type safety to runtime, closing the gap between your type declarations and the real world:
| Scenario | Approach |
|---|---|
| Basic field validation | Built-in decorators (@IsString, @IsEmail, etc.) |
| Nested objects | @ValidateNested() + @Type() |
| Custom business rules | Custom validator functions |
| Reuse create/update logic | PartialType inheritance |
| Enable globally | ValidationPipe with transform: true |
In our projects, we adopted the rule that all external input must pass through a DTO before reaching business logic. Debugging improved noticeably — invalid data is caught at the entry point rather than surfacing as a mysterious error deep in the call stack.
Author: ekent · ek Studio