Fix: NestJS ValidationPipe Not Working — class-validator Decorators Ignored
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix NestJS ValidationPipe not validating requests — global pipe setup, class-transformer, whitelist and transform options, custom validators, and DTO inheritance issues.
The Problem
NestJS ValidationPipe doesn’t reject invalid request bodies:
// DTO
export class CreateUserDto {
@IsEmail()
email: string;
@MinLength(8)
password: string;
}
// Controller
@Post()
async create(@Body() dto: CreateUserDto) {
// Receives the body even with invalid email or short password
// Validation decorators are ignored
}Or validation works but the body contains extra fields that should be stripped:
// Request body: { email: '[email protected]', password: 'secret123', isAdmin: true }
// dto.isAdmin exists even though it's not in the DTO — should be strippedOr a DTO property transforms incorrectly:
@IsInt()
age: number;
// POST body: { "age": "25" }
// Error: age must be an integer — even though the string "25" should convert to 25Or a nested DTO doesn’t validate:
export class CreateOrderDto {
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto; // AddressDto fields not validated
}Why This Happens
NestJS ValidationPipe uses class-validator for validation and class-transformer for type transformation. The pipe intercepts incoming request data, transforms the plain JSON object into an instance of the DTO class, and then runs class-validator decorators against that instance. If any step in this pipeline is misconfigured, validation silently skips.
The most fundamental requirement is that the DTO must be a class, not a TypeScript interface. Interfaces are erased at compile time — they produce no JavaScript output. When NestJS receives a request body and the parameter is typed as an interface, there is no class constructor to instantiate, no prototype to attach decorators to, and no metadata for class-validator to read. The body passes through as a plain object with zero validation. This is the single most common cause of “decorators are ignored” and produces no error or warning.
The second layer is configuration: ValidationPipe must be explicitly registered — either globally in main.ts, at the module level via APP_PIPE, or per-controller/per-route with @UsePipes(). NestJS does not enable validation by default. Without registration, request bodies are passed directly to controller methods as plain objects. The transform: true option is equally important — without it, the body remains a plain object even if the parameter type is a class, and class-validator decorators on class properties are not evaluated against plain objects. Missing class-transformer or class-validator packages, disabled emitDecoratorMetadata in tsconfig.json, or version mismatches between the two libraries compound the problem.
Fix 1: Register ValidationPipe Globally
The most common cause — ValidationPipe must be explicitly registered:
// main.ts — register globally (recommended)
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip properties not in the DTO
forbidNonWhitelisted: true, // Throw error if extra properties sent
transform: true, // Auto-transform payloads to DTO instances
transformOptions: {
enableImplicitConversion: true, // Convert string '25' to number 25
},
}),
);
await app.listen(3000);
}
bootstrap();Per-controller or per-route (less common):
// Controller-level
@UsePipes(new ValidationPipe({ whitelist: true }))
@Controller('users')
export class UsersController {}
// Route-level
@Post()
@UsePipes(new ValidationPipe({ whitelist: true, transform: true }))
async create(@Body() dto: CreateUserDto) {}Module-level registration (for dependency injection in custom validators):
// app.module.ts
import { APP_PIPE } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
@Module({
providers: [
{
provide: APP_PIPE,
useValue: new ValidationPipe({
whitelist: true,
transform: true,
}),
},
],
})
export class AppModule {}Note:
app.useGlobalPipes()doesn’t integrate with NestJS’s dependency injection system. For custom validators that inject services (e.g., checking if email is already taken in the database), use theAPP_PIPEprovider approach.
Scope matters: If you register a ValidationPipe both globally and on a specific route, the route-level pipe takes precedence for that route. This can cause confusion when the route-level pipe has different options (e.g., missing whitelist: true).
Fix 2: DTO Must Be a Class, Not an Interface
TypeScript interfaces are erased at runtime. A DTO typed as an interface has no class constructor, no prototype, and no decorator metadata:
// WRONG — interface is erased at runtime, no validation occurs
interface CreateUserDto {
email: string;
password: string;
}
@Post()
async create(@Body() dto: CreateUserDto) {
// dto is a plain object — class-validator sees no decorators
// No validation whatsoever
}
// CORRECT — class with decorators
export class CreateUserDto {
@IsEmail()
email: string;
@MinLength(8)
password: string;
}
@Post()
async create(@Body() dto: CreateUserDto) {
// dto is transformed into a CreateUserDto instance
// class-validator sees @IsEmail and @MinLength
}Also wrong — abstract class without decorators:
// Abstract classes work as types but miss decorators in common patterns
abstract class BaseDto {
email: string; // No decorator — not validated
}
export class CreateUserDto extends BaseDto {
@MinLength(8)
password: string;
// email is inherited but has no @IsEmail — not validated
}Fix 3: Install Required Packages
ValidationPipe with transform: true requires both class-validator and class-transformer:
npm install class-validator class-transformer
# Verify versions are compatible
npm list class-validator class-transformertsconfig.json — enable decorator metadata:
{
"compilerOptions": {
"experimentalDecorators": true, // Required for decorators
"emitDecoratorMetadata": true, // Required for NestJS type reflection
"strictPropertyInitialization": false // Allow uninitialized class properties in DTOs
}
}Without emitDecoratorMetadata: true, TypeScript doesn’t emit type metadata, and class-transformer can’t perform automatic type conversion.
Version compatibility: class-validator 0.14+ and class-transformer 0.5+ work together. Mixing class-validator 0.13 with class-transformer 0.5 can cause silent failures in nested validation. Check your lock file for version mismatches.
Fix 4: Fix Nested DTO Validation
@ValidateNested() alone doesn’t transform nested objects into class instances. Add @Type() from class-transformer:
import { Type } from 'class-transformer';
import { ValidateNested, IsString, IsNumber, IsArray } from 'class-validator';
export class AddressDto {
@IsString()
street: string;
@IsString()
city: string;
@IsString()
country: string;
}
export class CreateOrderDto {
@ValidateNested()
@Type(() => AddressDto) // Required: tells class-transformer to instantiate AddressDto
address: AddressDto;
// Array of nested objects
@IsArray()
@ValidateNested({ each: true }) // each: true validates every item in the array
@Type(() => OrderItemDto)
items: OrderItemDto[];
}Without @Type() — validation silently skips nested objects:
// WRONG — @ValidateNested without @Type
export class CreateOrderDto {
@ValidateNested()
address: AddressDto; // AddressDto fields NOT validated — plain object passed through
}
// CORRECT
export class CreateOrderDto {
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto; // AddressDto fields ARE validated
}Fix 5: Handle Type Transformation
transform: true converts plain request body values to class instances. enableImplicitConversion: true additionally converts primitive types:
export class PaginationDto {
@IsOptional()
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
// Query string: /users?page=2&limit=10
// Without enableImplicitConversion: page and limit are strings '2', '10' — validation fails
// With enableImplicitConversion: page and limit become numbers 2, 10 — validation passes
// In main.ts:
app.useGlobalPipes(new ValidationPipe({
transform: true,
transformOptions: {
enableImplicitConversion: true, // Strings → numbers/booleans based on TS type
},
}));Manual transformation with @Transform():
import { Transform } from 'class-transformer';
import { IsBoolean, IsDate } from 'class-validator';
export class FilterDto {
// Transform string 'true'/'false' to boolean
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
includeDeleted?: boolean;
// Transform ISO string to Date object
@Transform(({ value }) => value ? new Date(value) : undefined)
@IsDate()
@IsOptional()
startDate?: Date;
// Trim and lowercase string
@Transform(({ value }) => value?.trim().toLowerCase())
@IsString()
email?: string;
}Fix 6: Fastify vs Express Adapter Differences
NestJS supports both Express and Fastify as HTTP adapters. ValidationPipe itself works identically on both, but the raw body parsing and content type handling differ:
// Express adapter (default)
const app = await NestFactory.create(AppModule);
// Body parsing is handled by Express's built-in middleware
// Content-Type: application/json is parsed automatically
// Fastify adapter
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
// Fastify has its own body parser with different limits and behaviorFastify-specific gotchas:
// Fastify has a smaller default body size limit (1MB vs Express's 100KB default)
// If your request body is too large, Fastify rejects it before ValidationPipe runs
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ bodyLimit: 10 * 1024 * 1024 }), // 10MB
);
// Fastify does not parse multipart/form-data by default
// Install @fastify/multipart for file uploads
// With Express, you'd use multer instead
// Fastify's raw body access differs
// Express: req.rawBody (with rawBody option)
// Fastify: req.rawBody (with @fastify/raw-body plugin)@Body() with Fastify returns different types for edge cases:
// When Content-Type is text/plain:
// Express: @Body() returns a string (with text body parser)
// Fastify: @Body() returns the raw string by default
// When Content-Type is missing:
// Express: @Body() may return undefined
// Fastify: @Body() may return the raw buffer
// Always set Content-Type: application/json in your client requests
// to ensure consistent behavior across adaptersFix 7: Configure whitelist and forbidNonWhitelisted
whitelist: true strips properties not declared in the DTO. forbidNonWhitelisted: true throws an error instead of silently stripping:
// DTO — only declares email and password
export class LoginDto {
@IsEmail()
email: string;
@IsString()
password: string;
}
// Request body: { email: '[email protected]', password: 'secret', rememberMe: true }
// whitelist: false (default) — dto.rememberMe = true (extra field present)
// whitelist: true — dto.rememberMe undefined (stripped silently)
// forbidNonWhitelisted: true — 400 error: "property rememberMe should not exist"// Recommended production config:
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Strip extra fields
forbidNonWhitelisted: true, // Error on extra fields (catches typos in field names)
transform: true,
}));Allowlist specific non-DTO properties — if you need to pass through extra fields selectively, restructure the DTO:
// Can't use forbidNonWhitelisted if accepting arbitrary metadata
export class CreateEventDto {
@IsString()
name: string;
// Allow additional properties by typing explicitly
@IsObject()
@IsOptional()
metadata?: Record<string, unknown>;
// metadata will be validated as object but its keys won't be checked
}Fix 8: Write Custom Validators
For business logic validation (e.g., check if email is already registered), create custom validators:
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
registerDecorator,
ValidationOptions,
} from 'class-validator';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
// Custom constraint — can inject NestJS services
@ValidatorConstraint({ name: 'isEmailUnique', async: true })
@Injectable()
export class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async validate(email: string): Promise<boolean> {
const user = await this.userRepository.findOne({ where: { email } });
return !user; // Return true if valid (email not taken)
}
defaultMessage(args: ValidationArguments): string {
return 'Email $value is already registered';
}
}
// Custom decorator
export function IsEmailUnique(options?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options,
constraints: [],
validator: IsEmailUniqueConstraint,
});
};
}// DTO using the custom validator
export class RegisterDto {
@IsEmail()
@IsEmailUnique() // Async — checks database
email: string;
@MinLength(8)
password: string;
}// app.module.ts — register the constraint for dependency injection
// (Required for custom validators that inject services)
@Module({
providers: [
IsEmailUniqueConstraint, // Register the constraint
{
provide: APP_PIPE,
useFactory: () => new ValidationPipe({
whitelist: true,
transform: true,
}),
},
],
})
export class AppModule {}Fix 9: Handle Validation Errors
By default, ValidationPipe returns a 400 with all validation errors. Customize the response format:
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { ValidationError } from 'class-validator';
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
exceptionFactory: (errors: ValidationError[]) => {
// Flatten nested errors into a simple key-message object
const messages = errors.reduce((acc, error) => {
Object.values(error.constraints || {}).forEach(message => {
acc[error.property] = message;
});
return acc;
}, {} as Record<string, string>);
return new BadRequestException({
statusCode: 400,
error: 'Validation Failed',
messages,
});
},
}),
);
// Response format:
// {
// "statusCode": 400,
// "error": "Validation Failed",
// "messages": {
// "email": "email must be an email",
// "password": "password must be longer than or equal to 8 characters"
// }
// }Default NestJS validation error format:
{
"statusCode": 400,
"message": [
"email must be an email",
"password must be longer than or equal to 8 characters"
],
"error": "Bad Request"
}Still Not Working?
DTO class not imported correctly — if your DTO file has a typo or circular import, NestJS silently uses the wrong type. Check that @Body() dto: CreateUserDto imports CreateUserDto from the correct file.
@Body() without a DTO type — @Body() without a type annotation gives a plain object. @Body() body: any or @Body() body: object disables validation entirely. Always specify the DTO class.
Validation only for @Body() decorators — @Param(), @Query() don’t validate with class-validator by default unless the type is a DTO class. For query param validation, use a DTO class:
// CORRECT — validates query params using a DTO class
@Get()
async findAll(@Query() query: PaginationDto) {}
// Wrong — @Query('page') with a plain type doesn't validate
@Get()
async findAll(@Query('page') page: number) {} // page is a string — no validationskipMissingProperties: true — if set, fields without a value are not validated. This silently allows empty required fields. Only use it with @IsOptional() decorators, not as a global bypass.
DTO inheritance and @ValidateIf — if a child DTO extends a parent DTO, all parent decorators are inherited. But if the child overrides a property without re-applying decorators, the parent’s decorators are lost. Always re-apply decorators on overridden properties.
SWC compiler (instead of tsc) strips decorator metadata — if you use SWC as the TypeScript compiler (e.g., @swc/jest or NestJS 10+ default), emitDecoratorMetadata is not supported by SWC. Install @swc/core 1.3.50+ and add "legacyDecorator": true, "decoratorMetadata": true in .swcrc. Without this, implicit type conversion breaks silently.
For related NestJS issues, see Fix: NestJS Circular Dependency, Fix: NestJS Guard Not Working, Fix: NestJS Module Not Found, and Fix: NestJS Interceptor Not Triggered.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: NestJS Swagger UI Not Showing — /api-docs Returns 404 or Blank Page
How to fix NestJS Swagger UI not displaying — SwaggerModule setup, DocumentBuilder, decorators not appearing, guards blocking the docs route, and Fastify vs Express differences.
Fix: NestJS Nest can't resolve dependencies — Provider Not Found Error
How to fix NestJS dependency injection errors — module imports, provider exports, circular dependencies, dynamic modules, and the most common 'can't resolve dependencies' patterns.
Fix: NestJS Guard Not Working — canActivate Always Passes or Is Never Called
How to fix NestJS guards not working — applying guards globally vs controller vs method level, JWT AuthGuard, metadata with Reflector, public routes, and guard execution order.
Fix: NestJS Interceptor Not Triggered — Interceptors Not Running
How to fix NestJS interceptors not being called — global vs controller vs method binding, response transformation, async interceptors, execution context, and interceptor ordering.