Skip to content
All articles
5 min read

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-validator silently 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: true strips properties that are not declared in the DTO. A client sending isAdmin: true in a registration payload simply has it removed.
  • forbidNonWhitelisted: true goes 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: true converts the plain JSON payload into an actual DTO class instance. This is what makes type coercion work — a query string ?page=2 becomes the number 2 instead 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.
  • ValidationPipe is registered globally with whitelist, forbidNonWhitelisted, and transform enabled.
  • 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.

  • #NestJS
  • #TypeScript
  • #Backend
  • #Validation