DTOs and Validation Pipes
- nestjs
- node-js
- typescript
- backend-development
- api-design
- software-architecture
- validation
- rest-api
In Controllers and Routing I covered how requests get mapped to handlers, but I glossed over one detail: what actually happens to the request body between the HTTP layer and your handler function. That gap is where DTOs and validation pipes live, and it's one of the parts of NestJS that most directly separates a toy API from one you can trust in production.
This is part of the NestJS Production Guide series. If controllers are about deciding which function handles a request, DTOs and pipes are about deciding what that function is allowed to receive. Get this wrong and every downstream layer, your services, your database writes, your business rules, inherits the ambiguity.
I want to be upfront about scope. This covers the decisions that matter once you have real clients, real attackers, and real edge cases hitting your endpoints, not a rundown of class-validator decorator syntax.
Why validation belongs at the edge
A DTO (data transfer object) is just a TypeScript class describing the shape of data crossing a boundary, typically a request body, query string, or route params. On its own, a DTO is a compile-time contract. The compiler will happily let you claim a field is a string when the actual JSON payload has a number, a null, or nothing at all, because by the time your code runs, TypeScript's types have evaporated.
That's the gap validation pipes close. They run before your handler executes, check the incoming payload against runtime rules, and reject anything that doesn't match. The alternative, validating manually inside each service method, works until you have more than two or three endpoints. Then you end up with the same if (!body.email) throw new BadRequestException(...) scattered across a dozen files, each slightly different, each easy to forget when you add a new field.
The deeper reason to push validation to the edge is trust boundaries. Once a payload passes validation, everything downstream, your service layer, your repository, your queue consumer, can assume the shape is correct. Without that guarantee, every layer has to defensively re-check inputs, which is both wasted effort and a place for inconsistent rules to creep in.
Defining DTOs with class-validator
NestJS's conventional approach pairs a DTO class with class-validator decorators and the built-in ValidationPipe:
// dto/create-invoice.dto.ts
import {
IsString,
IsEmail,
IsInt,
Min,
IsOptional,
IsEnum,
IsUUID,
ArrayMinSize,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export enum InvoiceCurrency {
USD = 'USD',
EUR = 'EUR',
GBP = 'GBP',
}
class InvoiceLineItemDto {
@IsString()
description: string;
@IsInt()
@Min(1)
quantity: number;
@IsInt()
@Min(0)
unitPriceCents: number;
}
export class CreateInvoiceDto {
@IsUUID()
customerId: string;
@IsEmail()
billingEmail: string;
@IsEnum(InvoiceCurrency)
currency: InvoiceCurrency;
@ValidateNested({ each: true })
@Type(() => InvoiceLineItemDto)
@ArrayMinSize(1)
lineItems: InvoiceLineItemDto[];
@IsOptional()
@IsString()
notes?: string;
}
Registering the pipe globally means every controller gets this behavior without repeating boilerplate:
// main.ts
import { ValidationPipe } from '@nestjs/common';
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
Those four options are the ones I'd actually stop and think about before enabling by default:
whitelist: true strips any property not decorated in the DTO. Without it, an attacker (or just a sloppy frontend build) can send extra fields that silently pass through to your service layer. forbidNonWhitelisted: true goes further and rejects the request outright instead of quietly dropping the extra fields, which is the behavior I want in most APIs: fail loud, not silent.
transform: true is what lets @Type(() => InvoiceLineItemDto) and nested validation work, and it's also what turns route param strings into actual numbers when combined with ParseIntPipe or a typed DTO. Without it, every field arrives as whatever primitive JSON gave you, usually strings, and your validators have to account for that.
Nested objects and arrays need explicit typing
The nested lineItems array above is the part people get wrong most often. class-validator decorators only validate one level deep unless you tell it otherwise. If you skip @ValidateNested({ each: true }) and @Type(() => InvoiceLineItemDto), the outer DTO validates that lineItems is an array, but the objects inside it pass through unchecked. That means a request with lineItems: [{ description: 123, quantity: 'ten' }] sails through validation, and you find out about the type mismatch three layers deeper when your service tries to do arithmetic on a string.
This is worth testing explicitly rather than assuming it works, because the failure mode is silent. I'd rather write one integration test that sends a malformed nested payload and asserts a 400 than discover the gap from a production error.
Separate DTOs for separate purposes
A pattern I've settled on for anything beyond a small CRUD API: don't reuse one DTO across create, update, and response. They look similar early on and diverge fast.
export class CreateInvoiceDto {
// all fields required, as defined above
}
export class UpdateInvoiceDto {
@IsOptional()
@IsEmail()
billingEmail?: string;
@IsOptional()
@IsEnum(InvoiceCurrency)
currency?: InvoiceCurrency;
// deliberately no customerId: you don't reassign an invoice
// to a different customer through a partial update
}
export class InvoiceResponseDto {
id: string;
customerId: string;
billingEmail: string;
currency: InvoiceCurrency;
totalCents: number;
status: string;
createdAt: string;
}
You can lean on NestJS's PartialType from @nestjs/mapped-types to derive UpdateInvoiceDto from CreateInvoiceDto and cut the duplication, but do it deliberately, not by default. The moment an update DTO needs a field that create doesn't have, or needs to exclude a field that create requires, PartialType inheritance starts fighting you instead of helping. For a two-field DTO it's not worth the abstraction; for a twenty-field one, it usually is.
The response DTO deserves its own mention. Its job isn't validation. It exists to make sure your API doesn't leak internal fields (password hashes, internal notes, soft-delete flags) just because your ORM entity happens to have them. Pair it with class-transformer's @Exclude() on the entity or an explicit mapping function, and enable ClassSerializerInterceptor if you want the exclusion enforced automatically at serialization time rather than trusted to every controller author.
Custom pipes for logic validation can't express
class-validator decorators handle shape and format well: is this a string, is this a valid email, is this array non-empty. They handle badly anything that requires a database lookup or cross-field logic. For that, a custom pipe or a validator that injects a service is the right tool.
// pipes/unique-email.pipe.ts
import { Injectable, PipeTransform, BadRequestException } from '@nestjs/common';
import { CustomersService } from '../customers/customers.service';
@Injectable()
export class EnsureCustomerExistsPipe implements PipeTransform {
constructor(private readonly customersService: CustomersService) {}
async transform(customerId: string) {
const exists = await this.customersService.exists(customerId);
if (!exists) {
throw new BadRequestException(`Customer ${customerId} does not exist`);
}
return customerId;
}
}
I'm deliberately cautious about how far I push this pattern. A pipe that hits the database on every request is an extra query in the hot path, and it duplicates a check your service layer probably needs to make anyway for its own consistency guarantees (what if the customer is deleted between the pipe running and the transaction committing?). I reach for pipes like this to gate obviously malformed requests early and cheaply. I still enforce the real invariant, foreign key existence, uniqueness, ownership, inside the service or the database constraint. The pipe is a better error message, not the source of truth.
Production pitfalls worth naming directly
Validation errors that leak internals. The default ValidationPipe error response includes the property path and the raw constraint message, which is fine for an internal API but can expose your DTO structure to anyone probing a public endpoint. If that's a concern for you, override exceptionFactory to normalize the response shape before it reaches the client.
Forgetting transform: true and then debugging phantom bugs. Query params and route params arrive as strings. Without global transform enabled, a DTO field typed as number will hold the string "42" at runtime, and comparisons like age > 18 will silently do string coercion instead of throwing. This is one of those bugs that passes code review because the types look right.
DTOs that mirror the database schema exactly. It's tempting to make your create DTO match your entity one-to-one. Resist it. The DTO represents what the client is allowed to send; the entity represents what you store. They should diverge the moment you add a server-generated field, a computed value, or an internal status enum the client shouldn't set directly.
Skipping validation on nested arrays under load. @ValidateNested({ each: true }) on a large array runs the full validator pipeline per item. For most request bodies this is negligible, but if you accept bulk imports with thousands of nested rows through the same DTO used for single-record creation, measure it before assuming it scales the same way.
Key takeaways
- DTOs are compile-time contracts; validation pipes are what enforce them at runtime, and you need both.
- Enable `whitelist`, `forbidNonWhitelisted`, and `transform` on the global `ValidationPipe` as a default, then reason about exceptions per endpoint.
- Nested objects and arrays require explicit `@ValidateNested()` and `@Type()` decorators or they pass through unchecked.
- Keep create, update, and response DTOs separate once an endpoint has more than a handful of fields; shared DTOs diverge and become a liability.
- Use custom pipes for early, cheap rejection of malformed input, but keep real invariants like uniqueness and foreign key checks in the service or database layer.
- Don't let DTOs leak internal schema details; validation pipe errors and response serialization both need a second look before they're public-facing.
Frequently asked questions
Should I use class-validator or a schema library like Zod in NestJS?
`class-validator` is the framework default and integrates cleanly with Nest's decorator-based DI and pipe system, so it's the lower-friction choice if you're staying inside Nest conventions. Zod (or similar) is worth considering if you want a single schema shared between frontend and backend, or if you prefer inferring types from schemas rather than writing classes and decorators separately. Both are viable; the tradeoff is ecosystem fit versus type-inference ergonomics, not correctness.
Does ValidationPipe run before or after guards?
Pipes run after guards and after interceptors' pre-controller logic, but before the route handler itself. Guards decide whether the request is authorized to proceed at all; pipes then validate and transform the data that authorized request is carrying. If you need to reject a request before it's even parsed, that's a guard or middleware concern, not a pipe concern.
Can I validate query parameters the same way as the request body?
Yes. Decorate a DTO class for the query object and use `@Query() query: MyQueryDto` in the handler signature; the global `ValidationPipe` applies to it the same way it does to `@Body()`. The one difference is that query values always arrive as strings, so make sure `transform: true` is enabled or your `@IsInt()` checks will fail on values that are numerically correct but typed as strings.
Why does my DTO validation pass in development but reject valid requests in production?
This is almost always a serialization mismatch: either the client is sending a slightly different content type (missing `Content-Type: application/json`), or a proxy/load balancer is altering the body encoding. Check that `forbidNonWhitelisted` isn't rejecting fields the client added after a frontend deploy that hasn't been reflected in the DTO yet; that's a more common cause than an actual validation bug.
Is it worth writing custom decorators for validation rules I reuse often?
Yes, once you've copy-pasted the same combination of decorators (say, a strong-password check, or a slug-format string) across three or more DTOs. `class-validator` supports custom decorators through `registerDecorator`, and centralizing the rule means a security or format fix happens in one place instead of needing to be tracked down across every DTO that duplicated it.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.