Skip to content

Validation at the boundary: ValidationPipe, whitelisting, mass assignment

The pipe checks rules, not shapes; strip or refuse the rest, and give every write a DTO naming only what that client may change.

Predict first

Before you read: predict

ExamHall's candidate app saves an answer with PATCH /attempts/:id. The API's bootstrap registers one global pipe, and AttemptsService.update(id, data) writes each property of data onto the stored attempt:

Attempt a-7 is stored as answer null · score null. A candidate sends PATCH /attempts/a-7 with {"answer":"B","flagged":true,"score":100}.

After the reply, what is stored for a-7?

Code for this question
// main.ts (the bootstrap line)app.useGlobalPipes(new ValidationPipe()); // update-attempt.dto.tsimport { IsIn } from 'class-validator'; export class UpdateAttemptDto {  @IsIn(['A', 'B', 'C', 'D'])  answer!: string;   flagged?: boolean;} // attempts.controller.tsimport { Body, Controller, Param, Patch } from '@nestjs/common';import { UpdateAttemptDto } from './update-attempt.dto.js';import { AttemptsService } from './attempts.service.js'; @Controller('attempts')export class AttemptsController {  constructor(private attempts: AttemptsService) {}   @Patch(':id')  save(@Param('id') id: string, @Body() body: UpdateAttemptDto) {    return this.attempts.update(id, { ...body });  }}
Pick one

The 30-second version

A TypeScript type on @Body() checks nothing at run time. ValidationPipe builds the parameter's DTO class and runs its class-validator rules; anything without a rule still passes, unless whitelist strips it or forbidNonWhitelisted refuses it. Spreading an unchecked body into an update is mass assignment: accept only what this client may change.

The pipe checks rules, not shapes; strip or refuse the rest, and give every write a DTO naming only what that client may change.

How it works

Step 1 of 14

The type is no check. With no pipe, @Body() hands the handler the JSON exactly as sent; the DTO class is only a compile-time type.

Analogy: A vet practice's new-patient form (animal care)

The practice prints a registration form, and a rule card says what each ruled box must hold: microchip, 15 digits; species, dog, cat or rabbit.

At the practice In NestJS
the receptionist checks the ruled boxes, then keys in every line the owner wrote, so "staff discount: 50%" in the margin lands on the account the default pipe: rules checked, extras written (steps 5, 9)
under the strict keying policy, only ruled boxes are keyed, and a printed box with no rule is dropped too whitelist (step 6)
the "send it back" stamp, used only under the strict policy forbidNonWhitelisted (step 7)
a form the owner's app only described over the phone: no rule card to hold it against, so it is keyed unchecked no class, no check (step 3)
the stapled insurance slip is checked only when the form says to open it and names the card for it @ValidateNested() with @Type() (step 12)
changes arrive on a change-of-address slip, never on a fresh copy of the full form with its discount box an allow-listed update DTO (steps 10–11)

In real terms: a DTO class is a list of rules the pipe runs; what the rules do not mention is passed, stripped or refused, as the options say.

Breaks down when: a receptionist notices a strange margin note and asks. The pipe never notices anything its rules do not name.

Contrasting case: an endpoint that reads one named field, such as @Body('answer') answer: string, never spreads the rest of the body anywhere; the mass-assignment risk comes from copying the whole object.

Examples, run for real

4.1 ExamHall EXH-2710: the attempt update with no pipe

PATCH /attempts/:id took @Body() body: UpdateAttemptDto and called this.attempts.update(id, { ...body }), and the bootstrap registered no pipe. A candidate added two properties to the answer:

4.1 EXH-2710 no pipe, body {"answer":"B","status":"graded","score":100}: reply 200 · stored answer B · score 100 · status graded
4.1 MR !3010 whitelist on, UpdateAttemptDto declares only answer: reply 200 · stored answer B · score null · status open
4.1 MR !3010 same pipe, body {"answer":"E"}: reply 400 · stored answer null · score null · status open

The DTO type checked nothing (step 1), and the spread wrote status and score (step 9). MR !3010 registers new ValidationPipe({ whitelist: true }) and an update DTO that declares only answer, with @IsIn(['A', 'B', 'C', 'D']): the extras are stripped, and a bad answer stops the request (steps 4, 6, 10).

4.2 Streaming: the schema decides the extras

A video service renames profiles with @Body({ schema: renameSchema }) body: RenameDto, where renameSchema has one key, title: z.string().min(1), and the bootstrap registers new StandardSchemaValidationPipe(). A viewer sends {"title":"Kids","plan":"premium"} to a profile stored with title Old and plan basic:

4.2 renameSchema = z.object({...}): reply 200 · stored plan basic · title Kids
4.2 renameSchema = z.strictObject({...}): reply 400 · stored plan basic · title Old

The schema's own kind decided whether plan was stripped or refused (steps 13–14).

Picture it

Concept illustration for this lesson.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

A box office's API registers app.useGlobalPipes(new ValidationPipe({ whitelist: true })), and BookingsService.update(id, data) writes each property of data onto the stored booking. Staff create bookings, and each buyer edits their own:

Booking b-4 is stored as holderName Ana · priceCents 4500 · seatRow B. Its buyer sent PATCH /bookings/b-4 with {"holderName":"Ana Ruiz","priceCents":0}, and b-4 now has priceCents 0. Next sprint, @IsBoolean() refunded!: boolean joins BookingDto, a flag only staff may set.

Which one change stops the buyer's PATCH from writing priceCents, still lets the buyer change holderName or seatRow alone, and keeps refunded out of the buyer's edits after that sprint?

Code for this question
// booking.dto.tsimport { IsIn, IsInt, IsString, Length, Min } from 'class-validator';import { PartialType } from '@nestjs/mapped-types'; export class BookingDto {  @IsString() @Length(1, 60)  holderName!: string;   @IsIn(['A', 'B', 'C'])  seatRow!: string;   @IsInt() @Min(0)  priceCents!: number;} export class UpdateBookingDto extends PartialType(BookingDto) {} // bookings.controller.tsimport { Body, Controller, Param, Patch, Post } from '@nestjs/common';import { BookingDto, UpdateBookingDto } from './booking.dto.js';import { BookingsService } from './bookings.service.js'; @Controller('bookings')export class BookingsController {  constructor(private bookings: BookingsService) {}   @Post() // staff only  create(@Body() body: BookingDto) {    return this.bookings.create({ ...body });  }   @Patch(':id') // the booking's buyer  edit(@Param('id') id: string, @Body() body: UpdateBookingDto) {    return this.bookings.update(id, { ...body });  }}
Pick one

Question 2 of 6

A chat app's API registers app.useGlobalPipes(new ValidationPipe({ whitelist: true })), and RoomsService.update(id, data) writes each property of data onto the stored room:

tsc reports no errors. Room r-2 is stored as name general · ownerId u-1. A member sends PATCH /rooms/r-2 with {"name":"","ownerId":"u-9"}.

What is the reply, and what is stored for r-2?

Code for this question
// rename-room.dto.tsimport { IsString, Length } from 'class-validator'; export class RenameRoomDto {  @IsString() @Length(1, 40)  name!: string;} // rooms.controller.tsimport { Body, Controller, Param, Patch } from '@nestjs/common';import type { RenameRoomDto } from './rename-room.dto.js';import { RoomsService } from './rooms.service.js'; @Controller('rooms')export class RoomsController {  constructor(private rooms: RoomsService) {}   @Patch(':id')  rename(@Param('id') id: string, @Body() body: RenameRoomDto) {    return this.rooms.update(id, { ...body });  }}
Pick one

Question 3 of 6

A shop's API registers app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })), and ListingsService.update(id, data) writes each property of data onto the stored listing:

A seller sent PATCH /listings/l-3 with {"title":"Desk lamp","price":{"amountCents":1999,"featured":true}}; the reply was 200, and l-3's price was stored with featured true, a flag only staff may set.

Which one change to price's decorators makes that request get a 400, while {"title":"Desk lamp","price":{"amountCents":1999}} still gets a 200?

Code for this question
// update-listing.dto.tsimport { IsInt, IsObject, IsString, Min } from 'class-validator'; export class PriceDto {  @IsInt() @Min(1)  amountCents!: number;} export class UpdateListingDto {  @IsString()  title!: string;   @IsObject()  price!: PriceDto;} // listings.controller.tsimport { Body, Controller, Param, Patch } from '@nestjs/common';import { UpdateListingDto } from './update-listing.dto.js';import { ListingsService } from './listings.service.js'; @Controller('listings')export class ListingsController {  constructor(private listings: ListingsService) {}   @Patch(':id')  edit(@Param('id') id: string, @Body() body: UpdateListingDto) {    return this.listings.update(id, { ...body });  }}
Pick one

Question 4 of 6

A streaming service's watch-party API registers app.useGlobalPipes(new ValidationPipe({ forbidNonWhitelisted: true, transform: true })), and PartiesService.addMember(partyId, data) stores data as a member of that party:

A viewer sent POST /parties/p-5/members with {"displayName":"Lee","role":"host"}, got 201, and joined p-5 as its host.

Which one edit to the pipe's options object makes that request get a 400?

Code for this question
// join-party.dto.tsimport { IsString, Length } from 'class-validator'; export class JoinPartyDto {  @IsString() @Length(1, 24)  displayName!: string;} // parties.controller.tsimport { Body, Controller, Param, Post } from '@nestjs/common';import { JoinPartyDto } from './join-party.dto.js';import { PartiesService } from './parties.service.js'; @Controller('parties')export class PartiesController {  constructor(private parties: PartiesService) {}   @Post(':partyId/members')  join(@Param('partyId') partyId: string, @Body() body: JoinPartyDto) {    return this.parties.addMember(partyId, { role: 'guest', ...body });  }}
Pick one

Question 5 of 6

A payments API is moving its routes to Zod. Its bootstrap registers app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })), and RefundsService.create(paymentId, data) stores data as a new refund:

A client sent POST /payments/pay-1/refunds with {"amountCents":-500,"reason":"duplicate","approvedBy":"u-1"}; the reply was 201, and a refund of -500 was stored with approvedBy u-1.

Which one change makes that request get a 400, while {"amountCents":500,"reason":"duplicate"} still gets a 201?

Code for this question
// refund.schema.tsimport { z } from 'zod'; export const refundSchema = z.object({  amountCents: z.number().int().positive(),  reason: z.string().min(1),}); export type RefundDto = z.infer<typeof refundSchema>; // refunds.controller.tsimport { Body, Controller, Param, Post } from '@nestjs/common';import { refundSchema, RefundDto } from './refund.schema.js';import { RefundsService } from './refunds.service.js'; @Controller('payments')export class RefundsController {  constructor(private refunds: RefundsService) {}   @Post(':paymentId/refunds')  refund(@Param('paymentId') paymentId: string, @Body({ schema: refundSchema }) body: RefundDto) {    return this.refunds.create(paymentId, { ...body });  }}
Pick one

Question 6 of 6

A box office's API registers app.useGlobalPipes(new ValidationPipe({ whitelist: true })):

A clerk sends POST /events/e-9/holds with {"seats":["A1","A2"]}; the reply is a 500, and the log says body.label is not a function.

Where is the defect?

Code for this question
// hold-seats.dto.tsimport { ArrayMaxSize, IsString } from 'class-validator'; export class HoldSeatsDto {  @IsString({ each: true }) @ArrayMaxSize(6)  seats!: string[];   label() {    return `${this.seats.length} seat(s)`;  }} // holds.controller.tsimport { Body, Controller, Param, Post } from '@nestjs/common';import { HoldSeatsDto } from './hold-seats.dto.js'; @Controller('events')export class HoldsController {  @Post(':eventId/holds')  hold(@Param('eventId') eventId: string, @Body() body: HoldSeatsDto) {    return { eventId, held: body.label() };  }}
Pick one

Go deeper

Pro tip
  1. Register the pipe in a module with the APP_PIPE token, not only in main.ts, so every app built from that module, test apps included, validates the same way. When: writing an e2e test that builds the app from Test.createTestingModule, which never runs main.ts. Verify:
    tip 1 test app, ValidationPipe({ whitelist: true }) only in main.ts: reply 200 · stored answer B · score 100 · status graded
    tip 1 test app, the same pipe as an APP_PIPE provider: reply 200 · stored answer B · score null · status open
Source

The NestJS and library pages are vendor documentation that changes with releases, so their claims are labelled as of 2026-10-03; every behaviour this lesson grades is also executed on the pinned versions.

Checked against the tools and docs as of 2026-10-03.