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:

  • whitelist strips properties that have no validation decorator.
  • forbidNonWhitelisted rejects the request when such properties are present.
  • transform passes 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 as z.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 @Param and @Query. Either check metadata.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 refine makes safeParse throw, so those schemas need a variant that calls safeParseAsync.
  • 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 receives ValidationError[]. Walk each error's children recursively, join the property names into a dotted path, and emit one detail per key in constraints. Use the constraint name (isEmail, min) as the code.
  • Zod pipe: in the catch block of the custom pipe from section 2, map error.issues. Join each path with dots and pass code and message through.

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.