DTO validation in NestJS: why role "admin" and a mistyped address still get through Basic
Estimated reading time: 10 min read
Read first
The problem
Section titled “The problem”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?
What to choose? (summary first)
Section titled “What to choose? (summary first)”| 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 |
Core concepts
Section titled “Core concepts”| 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.
How it works
Section titled “How it works”Per the ValidationPipe source (@nestjs/common 11.2.6), a body with role and a mistyped address goes through these steps:
{ role, address }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 |
Operational details
Section titled “Operational details”whiteliststrips properties with no validation decorator at all.@IsOptional()counts as a decorator.forbidNonWhitelistedonly takes effect withwhitelistenabled; the error readsproperty 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: trueonly converts types automatically for single named parameters, such as@Query('active') active: boolean. enableImplicitConversionconverts based on the property’s declared type. For booleans, the string"false"becomestrue.- Default errors: HTTP 400,
messageis an array of strings; nested errors look likeaddress.zip ..., array elements likeshippingAddresses.1.zip .... PartialTypefor PATCH DTOs lives in the separate@nestjs/mapped-typespackage (not installed in the demo repo). Per the docs, it keeps validation decorators and marks every field optional.
Real-world example
Section titled “Real-world example”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.
Nested: missing one of the two decorators
Section titled “Nested: missing one of the two decorators”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"],...}Query params and enableImplicitConversion
Section titled “Query params and enableImplicitConversion”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 truePrefer
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true,}));// Query: @Type(() => Number) for numbers,// @Transform(({ value }) => value === 'true') for booleansWhy
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 and e2e tests
Section titled “exceptionFactory and e2e tests”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:
npx --prefix examples ts-node examples/src/nestjs-validation-dto/validation-demo.tsTrade-offs
Section titled “Trade-offs”Pros
-
Rules sit next to fields; reading the DTO shows the endpoint’s contract.
-
whitelistturns 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.
-
plainToInstanceandvalidatecost 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
Production checklist
Section titled “Production checklist”- Register
ValidationPipewithwhitelist: trueandforbidNonWhitelisted: trueviaAPP_PIPEso tests and production share it. - Every valid property has at least one decorator; optional fields use
@IsOptional(). - Every nested object has the
@ValidateNested()+@Type()pair; arrays addeach: trueand@ArrayMaxSize. - Cap string lengths and array sizes; a body with valid types can still be too large.
- Don’t enable
enableImplicitConversionglobally when DTOs have booleans; convert query types with@Typeor@Transform. - Split user and admin DTOs; don’t use ORM entities as DTOs.
- Write e2e tests that send unknown fields and mistyped nested values, expecting 400.
Common mistakes
Section titled “Common mistakes”| 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 |
Self-check
Section titled “Self-check”- Why does
new ValidationPipe()with no options return 400 for a mistypedaddressbut still letrolethrough? - The DTO lacks
@Typeonaddressandwhitelistis enabled: what does the handler receive asaddress, and why? - Why does
?active=falsebecometruewithenableImplicitConversion, while@Query('active') active: booleandoes not? - An e2e test using
Test.createTestingModulereturns 200 for a body withrole. Where do you fix it so the test reflects production?
Summary
Section titled “Summary”References
Section titled “References”- Validation — NestJS:
ValidationPipeoptions,whitelist,transform, implicit conversion, mapped types. - Pipes — NestJS: global pipes and registering with
APP_PIPE. - class-validator README:
@ValidateNested,each, whitelisting, theValidationErrorstructure. - class-transformer README:
plainToInstance,@Typefor nested objects and arrays.