NestJS Validation Done Right: DTOs, Pipes, and Global Errors
Stop bad input from reaching your NestJS services. A practical guide to DTOs, class-validator, global ValidationPipe, and error responses your frontend can actually use.
Gusti Randa
Flutter & Full-Stack Developer
Why Validation Deserves a First-Class Design
Every NestJS API I have shipped in production eventually hit the same wall: bad input slipping past the controller and exploding somewhere deep in the service layer. A missing field becomes a database constraint error. A string where a number should be becomes a silent NaN in a price calculation. The fix is never "add more checks" — it is to make validation a structural part of the request pipeline, so invalid data physically cannot reach your business logic.
NestJS already ships the pieces for this: DTOs, class-validator, class-transformer, and pipes. The trick is wiring them together deliberately instead of sprinkling decorators and hoping for the best.
The DTO Is Your Contract
A Data Transfer Object is not a formality. It is the single source of truth for what your endpoint accepts. When the DTO changes, the API contract changes — and that is exactly what you want, because the change is visible in one place.
import { IsEmail, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class CreateUserDto {
@IsString()
@MaxLength(80)
name: string;
@IsEmail()
email: string;
@IsInt()
@Min(0)
@IsOptional()
age?: number;
}
A few rules I follow here:
- Every field gets a decorator. An undecorated field is an unvalidated field, and
class-validatorsilently ignores it. - Use
@IsOptional()for genuinely optional fields. Without it, a missing optional field fails validation. - Keep DTOs flat and boring. Nesting is fine when the payload truly nests, but a DTO that mirrors your database entity is a smell — the API contract and the storage model evolve at different speeds.
One Global Pipe to Rule Them All
Validation only runs if a pipe executes it. The most common mistake I see in NestJS projects is registering ValidationPipe per-controller or, worse, forgetting it entirely on one route. Register it once, globally, in main.ts:
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,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(3000);
}
bootstrap();
These three options do the heavy lifting:
whitelist: truestrips properties that are not declared in the DTO. A client sendingisAdmin: truein a registration payload simply has it removed.forbidNonWhitelisted: truegoes further and rejects the request outright when unknown properties appear. I enable this on public-facing APIs, where a typo'd field should be an error, not a silent no-op.transform: trueconverts the plain JSON payload into an actual DTO class instance. This is what makes type coercion work — a query string?page=2becomes the number2instead of staying a string.
Numbers, Booleans, and the Query-String Trap
transform: true alone does not fix everything. Query parameters and route parameters always arrive as strings, and class-validator checks the runtime type. A DTO like this will fail on a perfectly reasonable request:
export class ListUsersQueryDto {
@IsInt()
@Min(1)
page = 1;
}
GET /users?page=2 fails validation because page is the string "2", not the number 2. The fix is @Type(() => Number) from class-transformer, which tells the transformation step how to coerce the value:
import { Type } from 'class-transformer';
import { IsInt, Min } from 'class-validator';
export class ListUsersQueryDto {
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@Type(() => Number)
@IsInt()
@Min(1)
limit = 20;
}
The same pattern applies to booleans (@Transform(({ value }) => value === 'true')) and dates (@Type(() => Date)). If a validation error only appears on GET endpoints, the type coercion is almost always the culprit.
Error Responses Your Frontend Can Actually Use
By default, ValidationPipe returns a 400 response with a message array of raw constraint strings. That is fine for debugging, but a frontend team needs structure: which field failed, and why. A custom exceptionFactory turns the flat array into a field-keyed object:
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
exceptionFactory: (errors) => {
const fields = errors.reduce(
(acc, err) => {
acc[err.property] = Object.values(err.constraints ?? {});
return acc;
},
{} as Record<string, string[]>,
);
return new BadRequestException({
message: 'Validation failed',
fields,
});
},
});
The response becomes something a form can render directly:
{
"message": "Validation failed",
"fields": {
"email": ["email must be an email"],
"age": ["age must not be less than 0"]
}
}
One caution: err.constraints can be undefined for nested validation errors, which is why the ?? {} guard matters. And for nested DTOs, use @ValidateNested() with @Type(() => ChildDto) so child errors surface with their own property paths instead of vanishing.
Validate at the Boundary, Not in the Service
A pattern I keep coming back to: the controller owns the shape of the data, the service owns the rules of the domain. Validation decorators answer "is this payload well-formed?" — the service answers "is this operation allowed?" A duplicate email is a business rule and belongs in the service; a malformed email is a contract violation and belongs in the DTO.
Keeping that line clean pays off in tests. Unit-testing a service no longer requires constructing valid DTOs through the validation layer, and end-to-end tests can assert on the exact 400 shape for malformed input without touching business logic.
A Checklist Before You Ship
- DTOs declare every accepted field, with a decorator on each one.
ValidationPipeis registered globally withwhitelist,forbidNonWhitelisted, andtransformenabled.- Query and route parameters use
@Type()or@Transform()for coercion. - Error responses are field-keyed so clients can act on them.
- Nested objects use
@ValidateNested()and@Type().
Validation is not glamorous work, but it is the cheapest reliability you can buy in a NestJS API. Get it right once, globally, and every endpoint you add afterwards inherits it for free.
Keep reading
Riverpod vs BLoC in 2026: What I'd Pick for Production
A practical, production-focused comparison of Riverpod and BLoC for Flutter apps in 2026 — boilerplate, testability, and when each one actually wins.
My Journey into the IT World: From Web to Mobile Development
From my early days in web development to building cross-platform mobile apps with Flutter, my journey in the IT world has been a continuous learning experience.