Stop Hand-Rolling Input Checks in NestJS: Use ValidationPipe as a Contract
Stop writing manual input checks. Use NestJS ValidationPipe with class-validator to enforce request contracts, transform payloads, and centralize error formatting.
06 May 2026, 15:29 UTC

Endpoints that accept raw JSON often end up with ad-hoc checks scattered across handlers: type guards, manual null checks, and inconsistent error shapes. The useful takeaway is to treat the request body as a contract and let NestJS enforce it before your handler ever runs.
NestJS provides a built-in ValidationPipe that pairs class-validator decorators with class-transformer to coerce and validate incoming payloads against Data Transfer Object (DTO) classes. Applied globally, it turns input shape enforcement into declarative metadata rather than imperative code.
Declarative shape with DTOs and transformation
The class-validator library provides constraints like @IsString, @IsInt, @IsEmail, and @Length. Because incoming network requests are plain JavaScript objects, class-transformer is used to create actual class instances from those objects, allowing the decorators to execute against real properties.
A minimal DTO for a user creation endpoint looks like this:
import { IsEmail, IsString, Length, IsInt, Min } from 'class-validator';
export class CreateUserDto {
@IsString()
@Length(1, 100)
name: string;
@IsEmail()
email: string;
@IsInt()
@Min(13)
age: number;
}The controller remains thin because the pipe guarantees the type and validity of the data before it reaches the method:
@Post()
create(@Body() dto: CreateUserDto) {
// dto is now a validated class instance, not a plain object
}Global configuration and custom error shapes
Enable the pipe once in main.ts so every route benefits without repetition. The exceptionFactory option is particularly useful for normalizing error responses to match your API's specific standard.
// main.ts
import { ValidationPipe, BadRequestException } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
exceptionFactory: (errors) => {
return new BadRequestException({
code: 'VALIDATION_ERROR',
details: errors.map(err => ({
property: err.property,
constraints: err.constraints
}))
});
},
}));
await app.listen(3000);
}With transform: true, plain JSON is converted to class instances. The whitelist: true and forbidNonWhitelisted: true settings strip unknown properties and reject requests containing them explicitly, which prevents accidental mass-assignment vulnerabilities.
For more complex checks, async validators are supported. A custom validator that returns a Promise is awaited by the pipe, keeping the validation pipeline non-blocking while allowing for external checks, such as verifying if a username is already taken in a database.
Trade-offs and validation boundaries
ValidationPipe runs synchronously per constraint. On very large payloads with deeply nested objects, the cumulative processing cost can add noticeable latency. For bulk imports or massive JSON arrays, consider moving heavy checks to a background job or using a more performant schema validator.
Crucially, DTO validation is for input shape, not business rules. Repeating domain invariants—such as "a user cannot be created if the organization is over capacity"—inside decorators duplicates logic that belongs in a service or a guard. Use the pipe for type, format, and range; defer authorization and domain state to your business layer.
To verify the result, wire a simple DTO, enable the global pipe, and send a request with invalid data using a tool like curl or Postman. Check that the response matches the shape produced by your exceptionFactory and that extra fields are rejected when forbidNonWhitelisted is active.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.