How request validation works in NestJS with class-validator and with Zod
NestJS has no built-in validator. It runs pipes on handler arguments before your code executes, and validation is a pipe that either returns a value or throws. The two common approaches differ in where the rules live.
class-validator: the DTO class is the contract
The built-in ValidationPipe exists because NestJS already uses classes and decorators heavily, so describing a request body as a decorated class fits the rest of the framework. For each argument, the pipe reads the parameter's TypeScript type through emitted metadata. It calls class-transformer's plainToInstance to turn the raw JSON into an instance of that DTO, then runs class-validator's validate against the decorators on its properties. Three options change what reaches your handler:
whitelistremoves properties that have no validation decorator.forbidNonWhitelistedrejects the request instead of silently stripping those properties. It only applies whenwhitelistis on.transformpasses the transformed DTO instance to the handler. Without it, the handler receives the original plain object, even though validation ran against an instance.
Zod: the schema is the contract
Zod comes from outside NestJS. It defines a runtime schema and derives the static type from that schema. To use it, you write a small PipeTransform that takes a schema, calls schema.safeParse(value), and returns result.data on success. On failure, it maps each entry in ZodError.issues (path and message) into a BadRequestException. You attach the pipe per parameter, such as @Body(new ZodValidationPipe(schema)), or per route with @UsePipes.
The class-validator approach validates a class that happens to have a type. The Zod approach validates a schema that happens to produce one.
import { BadRequestException, Body, Controller, Injectable, PipeTransform, Post, ValidationPipe } from '@nestjs/common';
import { IsEmail, IsString, MinLength } from 'class-validator';
import { z, ZodType } from 'zod';
// class-validator: the decorated class is the contract (and the TS type)
class CreateUserDto {
@IsEmail() email!: string;
@IsString() @MinLength(8) password!: string;
}
// Zod: the schema is the contract; the static type is derived from it
const createUserSchema = z.object({ email: z.string().email(), password: z.string().min(8) });
type CreateUserInput = z.infer<typeof createUserSchema>;
@Injectable()
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodType<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (result.success) return result.data; // parsed output; z.object strips unknown keys by default
// Map each Zod issue to a flat { path, message } entry in a 400 response
const errors = result.error.issues.map((i) => ({ path: i.path.map(String).join('.'), message: i.message }));
throw new BadRequestException(errors);
}
}
@Controller('users')
export class UsersController {
// plainToInstance -> validate; extra keys rejected; handler receives a CreateUserDto instance
@Post('dto')
createWithDto(@Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })) dto: CreateUserDto) {
return { isInstance: dto instanceof CreateUserDto };
}
// safeParse -> result.data; handler receives a plain object typed by the schema
@Post('zod')
createWithZod(@Body(new ZodValidationPipe(createUserSchema)) input: CreateUserInput) {
return { email: input.email };
}
}
The same request contract written as a decorated DTO validated by the built-in ValidationPipe and as a Zod schema validated by a small custom PipeTransform that turns ZodError issues into a BadRequestException.
Type inference: one source of truth or two
With Zod, the schema is the only definition. The handler parameter is declared as z.infer<typeof CreateUserSchema>, so the static type is computed from the same object the pipe executes at runtime. If someone changes age from z.string() to z.number(), every handler that reads body.age as a string stops compiling. The check and the type cannot disagree because there is only one of them.
class-validator keeps two parallel declarations on each property: the TypeScript annotation and the decorator. Nothing connects them. A property written as age: number with @IsString() compiles without complaint. At runtime the pipe accepts "42", and the handler then treats it as a number. These mismatches tend to appear in code review long after they cause bugs.
The class approach also depends on compiler machinery. It needs experimentalDecorators, emitDecoratorMetadata and reflect-metadata, because ValidationPipe reads the emitted parameter type to decide what to validate. That metadata fails quietly in several cases:
- Interfaces erase to
Object, so the pipe skips validation entirely. - Union types also emit
Object, with the same result. - Circular imports can leave a referenced class
undefinedat decoration time. You need lazy@Type(() => X)references to work around it.
Zod has costs too. Large or deeply composed schemas slow down type-checking. Inferred types show up in editor hovers as long expanded structures, and assignment errors can run to many lines. Inferred types also describe plain objects. A DTO class can carry methods, getters and defaults, and parse() does not produce an instance with those unless you add a transform that constructs one.
import { Body, Controller, PipeTransform, Post } from '@nestjs/common';
import { IsString } from 'class-validator';
import { z } from 'zod';
// Zod: the schema is the single definition; the static type is derived from it.
const CreateUserSchema = z.object({
name: z.string(),
age: z.number().int(),
});
type CreateUserDto = z.infer<typeof CreateUserSchema>; // { name: string; age: number }
// Minimal pipe: runs the same schema object the type was computed from.
class ZodPipe<T extends z.ZodType> implements PipeTransform {
constructor(private readonly schema: T) {}
transform(value: unknown): z.infer<T> {
return this.schema.parse(value); // throws ZodError on invalid input
}
}
// class-validator: annotation and decorator are separate, unconnected claims.
class CreateUserClassDto {
@IsString()
name!: string;
@IsString() // wrong check: accepts "42"; still compiles cleanly
age!: number;
}
@Controller('users')
export class UsersController {
@Post('zod')
createZod(@Body(new ZodPipe(CreateUserSchema)) body: CreateUserDto) {
// Change age to z.string() and this line stops compiling.
return { ageRounded: body.age.toFixed(0) };
}
@Post('class') // assumes a global ValidationPipe
createClass(@Body() body: CreateUserClassDto) {
return { ageRounded: body.age.toFixed(0) }; // TypeError at runtime for "42"
}
}
The Zod handler's parameter type is computed from the schema the pipe executes. The class-validator DTO declares age as a number but validates it as a string, and it still compiles.
Coercion, nested objects and unknown keys: where data slips through
.transform() that constructs one.
Query strings and route params always arrive as strings because they are parsed from the URL, not from a typed JSON body. Neither library sees ?limit=10 as a number until something converts it. With class-validator, ValidationPipe with transform: true and enableImplicitConversion lets class-transformer convert values based on the reflected TypeScript type. That conversion calls Boolean(value), so ?active=false becomes true. Zod has the same problem in z.coerce.boolean(), which also calls Boolean(). In both libraries, map "true" and "false" explicitly, either with a transform in class-transformer or with a preprocess step or an enum-then-transform in Zod.
Nested objects are a common place for unvalidated data. @ValidateNested() only checks instances. Without @Type(() => AddressDto), the nested value stays a plain object with no metadata attached. Depending on forbidUnknownValues, it either passes unchecked or fails with a generic error. Arrays of DTOs and Date fields need the same @Type treatment. In Zod, you nest one schema inside another and the nested schema is validated.
Unknown keys are a mass-assignment risk. ValidationPipe keeps them unless you set whitelist, and it only rejects them if you also set forbidNonWhitelisted. Zod objects strip unknown keys by default. .strict() makes Zod reject them, and .passthrough() keeps them.
For payloads whose shape depends on a type field, z.discriminatedUnion narrows both validation and the inferred type. class-validator needs @ValidateIf chains, a custom validator, or class-transformer's discriminator option on @Type.
| Concern | class-validator | Zod |
|---|---|---|
| String → number/date | Implicit conversion or @Type |
z.coerce, .transform, .preprocess |
"false" boolean |
Becomes true |
Becomes true with z.coerce.boolean() |
| Nested validation | Needs @ValidateNested + @Type |
Nest the schema |
| Unknown keys default | Kept | Stripped |
| Reject unknown keys | forbidNonWhitelisted |
.strict() |
| Tagged unions | @ValidateIf / custom / discriminator |
z.discriminatedUnion |
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsInt, IsString, ValidateNested } from 'class-validator';
import { z } from 'zod';
// class-validator: query values arrive as strings, so map booleans explicitly
class ListQueryDto {
@Type(() => Number) @IsInt() limit: number;
// Implicit conversion would run Boolean('false') === true; compare the string instead
@Transform(({ value }) => value === 'true') @IsBoolean() active: boolean;
}
class AddressDto { @IsString() city: string; }
class CreateUserDto {
// Without @Type, address stays a plain object and its rules never run
@ValidateNested() @Type(() => AddressDto) address: AddressDto;
}
// Zod: same pitfall, z.coerce.boolean() also turns 'false' into true
const listQuery = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
active: z.enum(['true', 'false']).transform((v) => v === 'true'),
});
// Nesting a schema validates it; .strict() rejects unknown keys instead of stripping
const createUser = z
.object({ address: z.object({ city: z.string() }) })
.strict();
// The 'type' field narrows both validation and the inferred TypeScript type
const payment = z.discriminatedUnion('type', [
z.object({ type: z.literal('card'), last4: z.string().length(4) }),
z.object({ type: z.literal('bank'), iban: z.string() }),
]);
type Payment = z.infer<typeof payment>;
listQuery.parse({ limit: '10', active: 'false' }); // { limit: 10, active: false }
createUser.parse({ address: { city: 'Lyon' }, isAdmin: true }); // throws: unrecognized key
Side-by-side handling of string query coercion, the 'false' boolean pitfall, nested validation, unknown keys and discriminated unions in class-validator DTOs and Zod schemas.
OpenAPI docs, error shapes and reuse beyond HTTP
@nestjs/swagger builds OpenAPI documentation by reading the same class metadata that ValidationPipe uses. Its CLI plugin can also infer properties from TypeScript types. A decorated DTO therefore produces documentation with no extra work, and @ApiProperty adds descriptions and examples in the same place as the validation rules.
Zod schemas have no class, so Nest has nothing to reflect on. You have two options:
- Wrap each schema in a DTO class.
- Add a schema-to-OpenAPI bridge library.
Either way, you add a build step and a dependency. Descriptions and examples are easy to lose in the conversion unless you attach them to the schema as metadata. If the generated docs are a separate artifact, they can fall behind the schema.
The default error shapes also differ:
ValidationPipereturns a 400 response withmessageset to an array of human-readable strings. These strings do not reliably include the field path.- A hand-written Zod pipe returns whatever you choose. That is often the raw
issuesarray, which containspath,codeandmessagefor each issue.
Clients should not need to know which validation library sits behind an endpoint, so define one contract for both. A good shape is { code: "VALIDATION_FAILED", errors: [{ path, message }] }:
- For
ValidationPipe, write anexceptionFactorythat walks theValidationErrortree, recursing intochildrenfor nested objects. It should join property names into a dotted path and emit one entry per constraint. - For the Zod pipe, map each issue's
patharray to the same dotted string.
Reuse outside HTTP is where Zod has the clearest advantage. A Zod schema is a plain value with no decorators, so it can live in a shared package that a frontend imports without pulling in reflect-metadata. The same schemas can validate environment config at startup, queue message payloads, and responses from external APIs. Each of these is a trust boundary where controllers and pipes do not run.
import { BadRequestException, PipeTransform, ValidationPipe } from '@nestjs/common';
import type { ValidationError } from 'class-validator';
import type { ZodType } from 'zod';
type FieldError = { path: string; message: string };
// One error contract for every validation failure, whichever library ran.
const validationFailed = (errors: FieldError[]) =>
new BadRequestException({ code: 'VALIDATION_FAILED', errors });
// class-validator: recurse into children for nested objects and arrays,
// and emit one entry per failed constraint with a dotted path.
function flatten(errors: ValidationError[], parent = ''): FieldError[] {
return errors.flatMap((e) => {
const path = parent ? `${parent}.${e.property}` : e.property;
const own = Object.values(e.constraints ?? {}).map((message) => ({ path, message }));
return [...own, ...flatten(e.children ?? [], path)];
});
}
export const classValidatorPipe = new ValidationPipe({
whitelist: true,
transform: true,
exceptionFactory: (errors: ValidationError[]) => validationFailed(flatten(errors)),
});
// Zod: map each issue's path array to the same dotted string.
export class ZodValidationPipe<T> implements PipeTransform {
constructor(private readonly schema: ZodType<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (result.success) return result.data;
throw validationFailed(
result.error.issues.map((i) => ({ path: i.path.map(String).join('.'), message: i.message })),
);
}
}
A ValidationPipe exceptionFactory and a Zod pipe that both throw the same { code: "VALIDATION_FAILED", errors: [{ path, message }] } response, so clients see one error shape whichever library validated the request.
Side-by-side comparison of Zod and class-validator in NestJS
Those boundaries are where the differences add up. The table summarizes them.
| Dimension | class-validator (+ class-transformer) | Zod |
|---|---|---|
| Source of truth | DTO class with decorators | Schema object |
| Type inference | Class is the type, so decorators and TS types can drift | z.infer derives the type from the schema |
| Coercion style | @Type() or implicit conversion via transformOptions |
Explicit per field with z.coerce or .transform() |
| Nested validation wiring | @ValidateNested() plus @Type(() => Child) on every nested field |
Compose schemas directly |
| Unknown-key default | Kept unless whitelist: true is set |
Stripped by z.object. Use .strict() to reject or .passthrough() to keep |
| Unions and conditional rules | @ValidateIf, discriminator option on @Type |
z.discriminatedUnion, refine, superRefine |
| Swagger support | First-party via @nestjs/swagger decorators or CLI plugin |
Needs a community integration or a JSON Schema conversion step |
| Error format | ValidationError tree, flattened into a message string array by ValidationPipe |
ZodError.issues with path, code and message. You shape it in your own pipe |
| Reuse outside Nest | Possible, but needs plainToInstance and reflect-metadata |
Works in any TypeScript runtime |
| Runtime dependencies | class-validator, class-transformer, reflect-metadata | zod, plus a small custom pipe |
The two can coexist. NestJS pipes are bound per parameter, method or controller. A Zod pipe on new routes works alongside a global ValidationPipe, provided the global pipe skips Zod-typed parameters. Without that check, both pipes run on the same input.
Hand-written validation with typeof checks inside the controller is a third option. It rarely wins: types and checks drift, error shapes vary between routes, and nested data is handled inconsistently.
Who should use which, and when to avoid each
The choice depends less on which library is better in the abstract and more on where your contracts live and who consumes them.
| Situation | Better fit | Why |
|---|---|---|
| Large existing codebase with decorated DTOs | class-validator | Rewriting stable DTOs adds risk and no new behaviour |
| Generated Swagger docs are a hard requirement | class-validator | DTO classes already feed the Swagger module's metadata |
| Team thinks in class-based DTOs | class-validator | Matches NestJS defaults and its documentation |
| Contracts shared across packages or with a frontend | Zod | A schema is a plain value that any TypeScript consumer can import |
| Discriminated unions and cross-field refinements | Zod | Unions and refinements are first-class, not custom decorators |
| Strict inference with no drift | Zod | The type is derived from the schema, so it cannot disagree |
When to avoid each
Avoid Zod when generated OpenAPI docs are non-negotiable and you have no schema-to-OpenAPI bridge you trust. A bridge that misrenders a union or drops a refinement produces docs that look right but are wrong, and clients will build against them.
Avoid class-validator when union-heavy payloads or shared frontend types dominate the API. Every union becomes a custom validator or a discriminator workaround. Every shared type becomes a second hand-maintained interface.
Do not mix without a standard
Running both libraries side by side is workable, but only with written rules. Without them, one route strips unknown keys and another rejects them. A query string "5" becomes a number on one endpoint and fails validation on the next. Clients also receive two different error shapes. Decide on stripping, coercion and error format once, and enforce them in both pipes.
Migration path, development cost and engineering time
With those conventions fixed, migrate incrementally. Keep the global ValidationPipe for existing class-based DTOs, and bind a Zod pipe at the parameter or route level on endpoints you migrate. Both pipes must share the same rules: strip unknown keys (or reject them), coerce query and path parameters, and use one error format. If they don't, clients can't tell which pipe handled their request.
Migrate new endpoints first, then the ones with a history of validation bugs, such as deeply nested payloads, optional arrays and anything that has leaked extra fields into the database. Leave quiet, stable endpoints for last, or leave them alone.
The cost is broad. Every migrated DTO is rewritten, its tests are updated, and its Swagger decorators are replaced by whatever generates OpenAPI from your schemas. A mature service with few validation incidents may never recoup that.
The time savings are ongoing. Request types can't drift from validation because they are inferred from the schema. Nested objects and extra fields fail loudly instead of slipping through. The same schema also validates config, queue messages and generated client types.
Illustrative estimate (assumed numbers, not a benchmark)
- Payback (quarters) = (DTOs × hours per DTO) ÷ (validation bugs per quarter × hours per bug)
- Assume 60 DTOs at 1.5 hours each, including tests and docs, for a one-time cost of 90 hours.
- Assume 2 validation bugs per quarter at 6 hours each, or 12 hours saved per quarter.
- Payback = 90 ÷ 12 = 7.5 quarters.
Replace these inputs with your own numbers. If the payback runs past the service's expected lifetime, limit the migration to new code.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to zod vs class-validator nestjs, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.