Skip to content

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

FixDevs · (Updated: )

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

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 behavior

Fastify-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 adapters

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

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.

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