How the NestJS ValidationPipe and a Zod pipe process a request
The built-in ValidationPipe works on classes. When a request arrives, Nest reads the parameter's declared type from the design:paramtypes metadata that TypeScript emits. class-transformer converts the raw JSON payload into an instance of that DTO class, and class-validator then runs the decorators attached to each property. Three options shape what the handler receives:
whiteliststrips properties that have no validation decorator.forbidNonWhitelistedrejects the request when such properties are present.transformpasses the DTO instance, with its converted values, to the handler instead of the plain object.
That type lookup is also the weak point. It depends on emitDecoratorMetadata, experimentalDecorators and reflect-metadata being configured correctly. If the metadata is not emitted, the pipe does not fail loudly. Common causes are a transpiler setup that drops it, or a parameter typed as an interface rather than a class. In those cases the pipe finds no class to validate against and passes the payload through unchecked. Nested objects can be skipped in the same quiet way if their type information is missing.
A Zod pipe has none of that machinery. It is a small custom pipe that receives a schema explicitly, usually through its constructor, and calls schema.safeParse on the plain payload. On failure it throws an HTTP exception. On success it returns the parsed output, which is a plain object, not a class instance. Because the schema is passed directly, nothing depends on reflected metadata.
The underlying choice is where type truth lives. With ValidationPipe, it lives in classes and decorators, and the TypeScript type and the runtime rules sit side by side but are maintained separately. With Zod, the schema is the single source, and the static type is inferred from it.
ValidationPipe (classes) Zod pipe (schemas)
+-----------------------------------+ +-----------------------------------+
| raw JSON payload | | raw JSON payload |
+-----------------------------------+ +-----------------------------------+
v v
+-----------------------------------+ +-----------------------------------+
| read design:paramtypes (*) | | schema passed via constructor |
| needs emitDecoratorMetadata, | | no reflected metadata involved |
| experimentalDecorators and | | |
| reflect-metadata | | |
+-----------------------------------+ +-----------------------------------+
v v
+-----------------------------------+ +-----------------------------------+
| class-transformer: JSON -> DTO | | schema.safeParse(payload) |
+-----------------------------------+ +-----------------------------------+
v v
+-----------------------------------+ +-----------------------------------+
| class-validator runs decorators | | fail -> throw HTTP exception |
| whitelist: strip undecorated | | ok -> return parsed output |
| forbidNonWhitelisted: reject | | |
| transform: pass DTO instance | | |
+-----------------------------------+ +-----------------------------------+
v v
+-----------------------------------+ +-----------------------------------+
| handler: DTO class instance | | handler: plain parsed object |
+-----------------------------------+ +-----------------------------------+
(*) no metadata emitted, or param typed as an interface: no class is found,
so the payload passes through UNCHECKED and no error is raised.
ValidationPipe derives its rules from reflected class metadata and can silently skip validation when that metadata is missing, while a Zod pipe validates against an explicitly supplied schema and returns a plain object.
Implementing a minimal Zod validation pipe in NestJS
The pipe is a small class that implements PipeTransform. Its constructor accepts a Zod schema, and transform(value, metadata) calls schema.safeParse(value). On success it returns result.data, not the original value. That distinction matters: Zod strips unknown keys by default and applies any transforms or defaults, so the handler receives the cleaned object. On failure, map result.error.issues to a flat list of { path: issue.path.join('.'), message: issue.message } and throw a BadRequestException with that list.
There are two ways to apply it:
- Per parameter:
@Body(new ZodValidationPipe(createUserSchema)), with the parameter typed asz.infer<typeof createUserSchema>. The schema validates exactly one argument. - Per route:
@UsePipes(new ZodValidationPipe(createUserSchema)). Route-scoped pipes run against every argument of the handler, including@Paramand@Query. Either checkmetadata.type === 'body'inside the pipe, or prefer per-parameter binding.
The class-validator equivalent is a CreateUserDto class with @IsEmail() on email and @IsString() plus @MinLength(1) on name. A single global ValidationPipe configured with whitelist and transform handles it, and no per-handler wiring is needed.
That last point is the cost of the Zod approach. Your team now owns three things the built-in pipe handled for you:
- Error shape: you define and maintain the error format.
- Async refinements: a schema with an async
refinemakessafeParsethrow, so those schemas need a variant that callssafeParseAsync. - Wiring: a forgotten pipe on a new endpoint fails open, and the request passes through unvalidated.
None of this is enforced by the framework. Write these conventions down and check them in code review.
import { ArgumentMetadata, BadRequestException, Body, Controller, PipeTransform, Post, UsePipes } from '@nestjs/common';
import { IsEmail, IsString, MinLength } from 'class-validator';
import { z, ZodType } from 'zod';
export class ZodValidationPipe implements PipeTransform {
constructor(private readonly schema: ZodType) {}
transform(value: unknown, metadata: ArgumentMetadata) {
// Route-scoped pipes also see @Param/@Query; this pipe only validates the body
if (metadata.type !== 'body') return value;
// Sync parse: a schema with an async refine needs a variant that awaits safeParseAsync
const result = this.schema.safeParse(value);
if (!result.success) {
// The error shape is ours to define and keep stable
const errors = result.error.issues.map((i) => ({ path: i.path.map(String).join('.'), message: i.message }));
throw new BadRequestException(errors);
}
return result.data; // cleaned object: unknown keys stripped, defaults/transforms applied
}
}
const createUserSchema = z.object({ email: z.string().email(), name: z.string().min(1) });
type CreateUserInput = z.infer<typeof createUserSchema>;
// class-validator equivalent, handled by one global ValidationPipe({ whitelist: true, transform: true })
export class CreateUserDto {
@IsEmail() email!: string;
@IsString() @MinLength(1) name!: string;
}
@Controller('users')
export class UsersController {
// Per parameter: forget the pipe and the body passes through unvalidated
@Post()
create(@Body(new ZodValidationPipe(createUserSchema)) body: CreateUserInput) { return body; }
// Per route: the pipe runs for every argument, hence the metadata.type check above
@Post('bulk')
@UsePipes(new ZodValidationPipe(createUserSchema))
createViaRoute(@Body() body: CreateUserInput) { return body; }
// DTO version: no per-handler wiring, the global pipe validates it
@Post('dto')
createWithDto(@Body() body: CreateUserDto) { return body; }
}
A minimal Zod pipe that returns parsed data or throws a BadRequestException with flattened issues, applied per parameter and per route, next to the equivalent class-validator DTO that a global ValidationPipe handles.
class-validator vs Zod: types, coercion, nesting and unknown keys
…review them like any other API contract. The bigger risks, though, come from how each library interprets the data itself.
Type drift
A DTO states its type twice: once in TypeScript, once in decorators. They can disagree. Mark a property @IsOptional() but leave its type as string, and handlers receive undefined while the compiler promises a string. With Zod, z.infer<typeof schema> derives the type from the validator, so the two cannot diverge.
Coercion
Query strings and route params always arrive as strings. enableImplicitConversion converts them using the DTO's declared types, but booleans go through JavaScript truthiness, so "false" becomes true. z.coerce.boolean() behaves the same way. In both libraries, safe boolean parsing has to be written explicitly, for example z.enum(["true","false"]) followed by a transform, or a z.preprocess step.
Nesting
@ValidateNested() only applies the nested DTO's rules when @Type(() => ChildDto) tells class-transformer which class to instantiate. If @Type is missing, the nested object can pass without its inner rules ever running, and nothing reports an error. Zod composes z.object and z.array directly, so there is no extra annotation to forget.
Unknown keys
Without whitelist, ValidationPipe passes extra properties through. That creates a mass-assignment risk when the DTO is spread into an ORM call. Zod strips unknown keys by default, .strict() rejects them, and .passthrough() keeps them.
| Concern | class-validator | Zod |
|---|---|---|
| Type source | Declared separately | Inferred from schema |
"false" → boolean |
true (implicit) |
true with coerce, explicit fix needed |
| Nested objects | Needs @Type |
Native composition |
| Unknown keys default | Kept | Stripped |
| Reject unknown keys | forbidNonWhitelisted |
.strict() |
import { Type } from 'class-transformer';
import { IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { z } from 'zod';
class AddressDto {
@IsString()
@IsNotEmpty()
city: string;
}
class CreateUserDto {
// Drift: the decorator allows undefined, but the declared type is `string`.
// The compiler trusts the type, so handlers can receive undefined.
@IsOptional()
@IsString()
nickname: string; // should be `nickname?: string`
// Pitfall: no @Type. class-transformer leaves this as a plain object,
// so AddressDto's rules (city must be a non-empty string) are not applied.
@ValidateNested()
address: AddressDto;
// Fix: @Type tells class-transformer which class to instantiate.
@ValidateNested()
@Type(() => AddressDto)
billingAddress: AddressDto;
}
// Zod: one schema is the single source of truth, and nesting composes directly.
const AddressSchema = z.object({ city: z.string().min(1) });
const CreateUserSchema = z.object({
nickname: z.string().optional(),
address: AddressSchema,
billingAddress: AddressSchema,
});
// Inferred: { nickname?: string | undefined; address: { city: string }; billingAddress: { city: string } }
type CreateUser = z.infer<typeof CreateUserSchema>;
A class-validator DTO with an optional property whose TypeScript type disagrees with its decorators, plus a nested DTO missing @Type, shown next to an equivalent Zod schema whose inferred type and nested validation cannot drift apart.
Normalizing validation errors into one API contract
By default, ValidationPipe throws a BadRequestException whose body is { statusCode: 400, message: [...], error: "Bad Request" }. The message array holds flattened, human-readable strings such as "email must be an email", with nested properties prefixed by their parent path. That format works for a quick UI, but it is hard to consume programmatically: clients have to parse sentences to find out which field failed and why.
A ZodError exposes issues, a list where each entry carries a path array (for example ["items", 0, "sku"]), a machine-readable code and a message. This is structurally richer. If you rethrow it unchanged, though, clients see a different payload depending on which pipe handled the route, and a mixed codebase then leaks an implementation detail into your public API.
Define the contract first
Choose one shape, such as { statusCode, error, details: [{ path, code, message }] }, and make both pipes produce it:
- ValidationPipe: supply an
exceptionFactory. It receivesValidationError[]. Walk each error'schildrenrecursively, join thepropertynames into a dotted path, and emit one detail per key inconstraints. Use the constraint name (isEmail,min) as thecode. - Zod pipe: in the
catchblock of the custom pipe from section 2, maperror.issues. Join eachpathwith dots and passcodeandmessagethrough.
The codes still come from two different vocabularies. If clients branch on code, add a small translation table so that both libraries report the same failure under the same identifier, for example required or invalid_format. Otherwise you have only standardized the envelope, not the meaning inside it.
import { BadRequestException, PipeTransform, ValidationPipe } from '@nestjs/common';
import { ValidationError } from 'class-validator';
import { ZodError, ZodType } from 'zod';
type Detail = { path: string; code: string; message: string };
// Translate both vocabularies into one set of client-facing codes (example subset)
const CODES: Record<string, string> = {
isDefined: 'required', isNotEmpty: 'required',
isEmail: 'invalid_format', invalid_string: 'invalid_format', invalid_format: 'invalid_format',
min: 'too_small', minLength: 'too_small', too_small: 'too_small',
};
// Single error envelope shared by both pipes
const toError = (details: Detail[]) =>
new BadRequestException({ statusCode: 400, error: 'Bad Request', details });
// class-validator: recurse into children and build a dotted path (arrays yield items.0.sku)
function flatten(errors: ValidationError[], parent = ''): Detail[] {
return errors.flatMap((e) => {
const path = parent ? `${parent}.${e.property}` : e.property;
const own = Object.entries(e.constraints ?? {}).map(([name, message]) =>
({ path, code: CODES[name] ?? name, message }));
return [...own, ...flatten(e.children ?? [], path)];
});
}
export const validationPipe = new ValidationPipe({
whitelist: true,
exceptionFactory: (errors: ValidationError[]) => toError(flatten(errors)),
});
export class ZodValidationPipe implements PipeTransform {
constructor(private readonly schema: ZodType) {}
transform(value: unknown) {
try { return this.schema.parse(value); }
catch (error) {
if (!(error instanceof ZodError)) throw error; // only map validation failures
throw toError(error.issues.map((i) => ({
path: i.path.map(String).join('.'), code: CODES[i.code] ?? i.code, message: i.message,
})));
}
}
}
Both a ValidationPipe exceptionFactory and a custom Zod pipe emit the same { statusCode, error, details: [{ path, code, message }] } body. A shared translation table maps class-validator constraint names and Zod issue codes onto one set of identifiers.
Who should use Zod, and when to stay with class-validator
The library choice matters less than where the canonical definition of a request lives, and which tools need to read it.
Zod pays off when validation logic must run outside Nest. A schema is a plain value with no decorators or classes, so the same object can validate a form in the frontend, a job payload in a worker, and a message in a queue consumer. All of them infer their types from that schema. With class-validator, those consumers would need the DTO classes and decorator metadata, or a second hand-written definition that can drift.
Stay with class-validator when your tooling depends on decorators. The Nest Swagger module reads DTO classes and their property decorators directly. Zod has no class to reflect on, so an OpenAPI document needs a generation step or a bridging library. Check that path against your real DTOs, including unions and nested arrays, before you migrate.
| Situation | Better fit |
|---|---|
| Schemas shared with frontends, workers or consumers | Zod |
| OpenAPI generated from DTO classes | class-validator |
| Team won't treat the schema as the single source of truth | class-validator |
| New modules, legacy DTOs untouched | Hybrid |
Avoid Zod if the team will keep a separate interface "for clarity" next to each schema. You then have two definitions again, with extra runtime cost.
Shared schemas also couple release cycles. A tightened field in a shared package can break a frontend that deploys on a different schedule. Version that package deliberately and treat breaking schema changes like API changes.
Other schema-first libraries follow the same trade-off. A hybrid setup is a valid end state, not only a transition.
Migrating incrementally: running both pipes side by side
Keep the global ValidationPipe registered exactly as it is, and attach the Zod pipe only at the parameter level on new or rewritten routes. Existing DTOs keep working, and each new endpoint is a contained change you can review on its own.
What the global pipe does with non-class types
Global pipes run before route- and parameter-scoped pipes, so the ValidationPipe always sees the request first. It decides whether to validate by looking at the parameter's runtime metatype. A type inferred from a Zod schema is a type alias, so TypeScript emits Object as the metadata, and the pipe passes the value through untouched. That is the behavior you want, and it means no double validation for free.
The trap is wrapping a schema in a class, which teams often do to get OpenAPI metadata. Now the metatype is a real class with no class-validator decorators. With whitelist enabled, every property is stripped before the Zod pipe runs. With forbidNonWhitelisted, the request is rejected outright. If you need class wrappers, give them a marker the global pipe can recognize and skip, or move OpenAPI generation off the parameter type.
One error contract
Point both paths at the same formatter from the previous section: the ValidationPipe through its exceptionFactory, and the Zod pipe by throwing the same exception type. Clients should not be able to tell which validator handled a route.
The real cost is cognitive. Reviewers need to know which model a controller uses, and coercion rules differ between the two. Track the remaining DTOs so the dual state has a visible end.
Impact on development cost and engineering time
The effort balance is mostly front-loaded on one side and recurring on the other. Zod's costs arrive early and are visible. class-validator's costs are spread thin across every DTO change, which makes them easy to underestimate.
| Effort area | class-validator + ValidationPipe | Zod pipe |
|---|---|---|
| Type drift between DTO and runtime checks | Recurring: decorators and TS types can disagree silently | Removed: type is inferred from the schema |
| Nested objects | Forgotten @Type causes bugs that pass compilation |
Nesting is part of the schema |
| Shared validation with frontend or workers | Usually written twice | One schema package, imported where needed |
| Pipe ownership | Built in, maintained upstream | Your code, your tests, your upgrades |
| API docs | Swagger decorators integrate directly | Extra tooling or hand-maintained docs |
| Coercion | Implicit via transform options | Explicit per field, more verbose |
| Migration period | None | Two models in review until DTOs are converted |
To decide whether migrating pays off, compare recurring savings against one-off cost:
Illustrative estimate (assumed numbers, not a benchmark). Assume each migrated DTO takes 0.5 hours. Assume 60 DTOs, so the one-off cost is 30 hours. Assume drift and missing-@Type debugging costs 4 hours per month. Break-even = one-off cost ÷ monthly savings = 30 ÷ 4 ≈ 7.5 months. Plug in your own DTO count and incident history. If your team rarely hits drift bugs, the payback period may never arrive.
Do not count runtime speed on either side of the ledger. Validation cost depends on schema shape, nesting depth, and payload size. Neither library should be assumed faster until you profile your own hot paths.
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 nestjs zod validation, 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 route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
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.
- Consumers learn about a breaking change at review time rather than from production errors.
- 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.