Skip to content

Fix: NestJS ValidationPipe Not Working — class-validator Decorators Ignored

FixDevs ·

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 stripped

Or 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 25

Or 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. Several configuration gaps cause validation to silently skip:

  • ValidationPipe not registered globally or per-route — without app.useGlobalPipes(new ValidationPipe()), the pipe is never applied.
  • class-transformer not installedtransform: true requires class-transformer as a dependency.
  • Plain objects instead of class instancesclass-validator decorators only work on class instances. If transform: true isn’t enabled, NestJS passes plain objects (no class information), so decorators are ignored.
  • Missing @Type() decorator for nested DTOs@ValidateNested() alone doesn’t transform nested objects. @Type(() => NestedDto) from class-transformer is required.
  • emitDecoratorMetadata not enabled — TypeScript must emit decorator metadata for NestJS to read type information from class properties.

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 the APP_PIPE provider approach.

Fix 2: 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-transformer

tsconfig.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.

Fix 3: 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 4: 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 5: 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 6: 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 7: 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 validation

skipMissingProperties: 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.

For related NestJS issues, see Fix: NestJS Circular Dependency and Fix: NestJS Guard Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles