Skip to content
Knowledge Hub

DTO validation in NestJS: why role "admin" and a mistyped address still get through Basic

Estimated reading time: 10 min read

The PATCH /users/me API lets a user edit displayName, locale and address. The handler takes @Body() dto: UpdateMeDto and saves it to the database. The code compiles without errors, and the happy-path tests are all green.

A client also sends "role": "admin" and address.street = 42. The request returns 200 and role reaches the repository: this is mass assignment, where a client writes a field it is not allowed to change (see Secure coding). The mistyped address is saved too.

The question: why does the UpdateMeDto type block nothing, and how should ValidationPipe be configured so that both unknown fields and nested objects are checked?

Situation Choose Condition
Every API that accepts a body Global ValidationPipe with whitelist: true Every valid field has at least one decorator
The client should learn it sent an unknown field Add forbidNonWhitelisted: true The client accepts a 400 instead of a silent drop
The DTO has nested objects or arrays @ValidateNested() and @Type(() => X) Arrays add { each: true }
A query param needs to be a number transform: true + @Type(() => Number) or ParseIntPipe Avoid enableImplicitConversion for booleans
A custom error format (Problem Details) exceptionFactory Default is 400; this site uses 422 (see exception filters)
e2e tests must match production Declare the pipe with APP_PIPE in a module Instead of useGlobalPipes only in main.ts
Concept Meaning
DTO A class describing an endpoint’s input shape, carrying validation decorators
plainToInstance A class-transformer function: turns a plain object from JSON into an instance of the class
validate A class-validator function: reads the decorators on an instance and returns an array of ValidationError
Pipe The NestJS layer that validates and transforms parameters right before the handler

TypeScript types are erased at compile time. At runtime, dto: UpdateMeDto is just an object that Express parsed from JSON. What remains is the UpdateMeDto class in the parameter metadata (thanks to emitDecoratorMetadata) and the decorators on its properties. No decorators means no rules to check.

ValidationPipe is a pipe, so it runs after guards and interceptors (inbound), right before the handler, as described in Request lifecycle:

Loading diagram…

Consequence: a guard reading req.body sees data that has not been validated.

Per the ValidationPipe source (@nestjs/common 11.2.6), a body with role and a mistyped address goes through these steps:

A body with role and a mistyped address: whitelist drops role, @ValidateNested blocks address, the handler never runs
A body with role and a mistyped address: whitelist drops role, @ValidateNested blocks address, the handler never runsStep 1/6: The client sends role: "admin" and address.street = 42. The body is a plain object; the UpdateMeDto type exists only in metadata.ValidationPipeJSON bodyPATCH /users/meplainToInstanceUpdateMeDtowhiteliststrip unknown fieldsvalidateincluding nested400 Bad Requestmessage: string[]updateMe(dto)handler{ role, address }
JSON body → plainToInstance{ role, address }
Step 1/6

The client sends role: "admin" and address.street = 42. The body is a plain object; the UpdateMeDto type exists only in metadata.

Step Before After
1. plainToInstance(UpdateMeDto, body) Plain object, address also plain An UpdateMeDto instance; address becomes AddressDto only if @Type is present
2. validate(entity, options) with whitelist role still there role removed, or turned into an error with forbidNonWhitelisted
3. Per-property rules, recursing into nested Not checked An array of ValidationError
4a. Errors found exceptionFactory throws BadRequestException, the handler does not run
4b. No errors transform: true returns the instance; false returns the plain object
  • whitelist strips properties with no validation decorator at all. @IsOptional() counts as a decorator.
  • forbidNonWhitelisted only takes effect with whitelist enabled; the error reads property role should not exist.
  • Nested: @Type(() => AddressDto) makes class-transformer create an instance; @ValidateNested() makes class-validator descend into it. Arrays use @ValidateNested({ each: true }).
  • Query and route params are always strings. transform: true only converts types automatically for single named parameters, such as @Query('active') active: boolean.
  • enableImplicitConversion converts based on the property’s declared type. For booleans, the string "false" becomes true.
  • Default errors: HTTP 400, message is an array of strings; nested errors look like address.zip ..., array elements like shippingAddresses.1.zip ....
  • PartialType for PATCH DTOs lives in the separate @nestjs/mapped-types package (not installed in the demo repo). Per the docs, it keeps validation decorators and marks every field optional.

The demo examples/src/nestjs-validation-dto/validation-demo.ts (NestJS 11.2.6, class-validator 0.15.1, class-transformer 0.5.1) builds an app for each configuration, sends bodies with fetch, and prints the real responses. The DTO:

class AddressDto {
@IsString() @Length(1, 120) street!: string;
@Matches(/^\d{5,6}$/) zip!: string;
}
class UpdateMeDto {
@IsOptional() @IsString() @Length(1, 80) displayName?: string;
@IsOptional() @IsIn(['vi', 'en']) locale?: string;
@IsOptional() @ValidateNested() @Type(() => AddressDto) address?: AddressDto;
@IsOptional() @ArrayMaxSize(3) @ValidateNested({ each: true })
@Type(() => AddressDto) shippingAddresses?: AddressDto[];
}

The body {"displayName":"An","role":"admin"} through three configurations:

[1] No ValidationPipe -> 200 {"displayName":"An","role":"admin"}
[2] new ValidationPipe() -> 200 {"displayName":"An","role":"admin"}
[3] whitelist: true -> 200 {"displayName":"An"}
[4] + forbidNonWhitelisted: true -> 400 {"message":["property role should not exist"],...}

Configuration [2] does validate, but role still reaches the handler because nobody told the pipe to drop unknown fields.

Body {"displayName":"An","address":{"street":42,"zip":"abc"}}, real results:

DTO Configuration Response
Both @ValidateNested + @Type whitelist 400: address.street must be a string, address.zip must match ...
Missing @Type no whitelist 200, the mistyped address gets through
Missing @Type whitelist 200, address: {}: silent data loss
Missing @Type whitelist + forbidNonWhitelisted A confusing 400: address.property street should not exist
Missing @ValidateNested whitelist + forbidNonWhitelisted 200, the mistyped address gets through

Without @Type, address stays a plain object: none of AddressDto’s rules apply, and whitelist deletes all its keys. Without @ValidateNested, the validator never descends into address.

Nested arrays with each: true report the exact index of the broken element:

PATCH /users/me {"shippingAddresses":[{"street":"Le Loi","zip":"700000"},{"street":"","zip":"1"}]}
-> 400 {"message":["shippingAddresses.1.street must be longer than or equal to 1 characters",
"shippingAddresses.1.zip must match /^\\d{5,6}$/ regular expression"],...}

The ListUsersQuery DTO has limit?: number (@IsInt) and active?: boolean (@IsBoolean). Calling GET /users?limit=20&active=false:

[5] whitelist -> 400 ["...limit must be an integer number","active must be a boolean value"]
[6] + transform: true -> 400 (same as [5])
[7] + transformOptions.enableImplicitConversion: true
-> 200 {"limit":20,"active":true}
Single param @Query('active') active: boolean, transform: true
-> 200 {"active":false}

transform: true does not convert property types in a query DTO. Implicit conversion does, but "false" becomes true, just like Boolean("false"). In a separate experiment, it even turned street: 42 into "42", so @IsString() passed.

Configuring the global ValidationPipe

Common

app.useGlobalPipes(new ValidationPipe({
transform: true,
transformOptions: { enableImplicitConversion: true },
}));
// role gets through; ?active=false becomes true

Prefer

app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
// Query: @Type(() => Number) for numbers,
// @Transform(({ value }) => value === 'true') for booleans

Why

The bad version ignores unknown fields and guesses types from metadata. The good version blocks unknown fields, and every type conversion is written explicitly on the property.

exceptionFactory receives ValidationError[] (with children for nested values) and returns an exception. Demo [8] turns it into a list of fields and rules:

-> 400 {"type":"validation","errors":[{"field":"role","rules":["whitelistValidation"]},
{"field":"address.street","rules":["isLength","isString"]},{"field":"address.zip","rules":["matches"]}]}

To return Problem Details, format the error here or in an exception filter; see Problem Details.

useGlobalPipes in main.ts does not run inside Test.createTestingModule. Demo [9] sends role through the test app: 200. Demo [10] declares { provide: APP_PIPE, useValue: new ValidationPipe({...}) } in the module: 400, matching production.

Run the demo:

Terminal window
npx --prefix examples ts-node examples/src/nestjs-validation-dto/validation-demo.ts

Pros

  • Rules sit next to fields; reading the DTO shows the endpoint’s contract.

  • whitelist turns the DTO into an allowlist, blocking mass assignment at the edge.

  • 400 errors carry the field path (address.zip), so clients can fix them right away.

  • < slot="cons">
  • A missing decorator silently drops a rule, with no compile error.

  • plainToInstance and validate cost CPU for large bodies and long arrays.

  • They only check shape; permission to edit a field and business constraints still belong to the service.

Cons

  1. Register ValidationPipe with whitelist: true and forbidNonWhitelisted: true via APP_PIPE so tests and production share it.
  2. Every valid property has at least one decorator; optional fields use @IsOptional().
  3. Every nested object has the @ValidateNested() + @Type() pair; arrays add each: true and @ArrayMaxSize.
  4. Cap string lengths and array sizes; a body with valid types can still be too large.
  5. Don’t enable enableImplicitConversion globally when DTOs have booleans; convert query types with @Type or @Transform.
  6. Split user and admin DTOs; don’t use ORM entities as DTOs.
  7. Write e2e tests that send unknown fields and mistyped nested values, expecting 400.
Misconception Consequence What to do
The parameter’s TypeScript type is validation Any JSON reaches the handler Add decorators and enable ValidationPipe
new ValidationPipe() already blocks unknown fields role: "admin" reaches the repository Enable whitelist
@ValidateNested() is enough for nesting Nested values are skipped or emptied Add @Type(() => X)
A global pipe in main.ts also runs in e2e tests Tests are green, production behaves differently APP_PIPE, or call useGlobalPipes in the test setup
  1. Why does new ValidationPipe() with no options return 400 for a mistyped address but still let role through?
  2. The DTO lacks @Type on address and whitelist is enabled: what does the handler receive as address, and why?
  3. Why does ?active=false become true with enableImplicitConversion, while @Query('active') active: boolean does not?
  4. An e2e test using Test.createTestingModule returns 200 for a body with role. Where do you fix it so the test reflects production?