How does request validation work in NestJS with ValidationPipe and Zod pipes?

Both approaches plug into the same slot: a pipe that runs after guards and interceptors but before the handler, receiving the raw argument (body, query, or param) and either returning a value or throwing.

ValidationPipe

ValidationPipe relies on the parameter's metatype, which is the DTO class TypeScript records through emitDecoratorMetadata. It calls class-transformer's plainToInstance to turn the JSON object into an instance of that class. class-validator then walks the decorators registered on the class and validates each property. The options change what happens to the payload:

  • whitelist strips properties that carry no validation decorator.
  • forbidNonWhitelisted rejects those properties with a 400 instead of stripping them.
  • transform passes the class instance (and converted primitive params) to your handler, not a plain object.

The fragile part is the metatype. If your tsconfig doesn't emit decorator metadata, or your bundler drops it (esbuild-based setups commonly do), the pipe sees Object and skips validation entirely. Requests pass without errors, and nothing warns you.

Zod pipes

Zod has no built-in pipe, so you write a small PipeTransform that holds a schema. Its transform method calls schema.safeParse(value). On success it returns result.data, which is the parsed and possibly coerced output, not the input. On failure it throws a BadRequestException built from error.issues.

You attach the pipe explicitly, either per route with @UsePipes or per parameter, as in @Body(new ZodValidationPipe(schema)). Prefer the per-parameter form. A method-level pipe runs against every argument, so one schema would also be applied to params and queries it was never written for. Because the schema is passed in directly, this approach doesn't depend on reflection metadata.

import { BadRequestException, Body, Controller, PipeTransform, Post } from '@nestjs/common';
import { z, ZodType } from 'zod';

// Generic pipe: holds one schema and validates a single argument against it
export class ZodValidationPipe implements PipeTransform {
  constructor(private readonly schema: ZodType) {}

  transform(value: unknown) {
    const result = this.schema.safeParse(value);
    if (!result.success) {
      // Turn Zod issues into a 400 response body
      throw new BadRequestException({
        message: 'Validation failed',
        errors: result.error.issues.map((issue) => ({
          path: issue.path.map(String).join('.'),
          message: issue.message,
        })),
      });
    }
    // Return the parsed output (coerced values, unknown keys stripped), not the raw input
    return result.data;
  }
}

const createOrderSchema = z.object({
  sku: z.string().min(1),
  quantity: z.coerce.number().int().positive(),
});
type CreateOrderInput = z.infer<typeof createOrderSchema>;

@Controller('orders')
export class OrdersController {
  @Post()
  // Per-parameter: the schema applies to the body only, not to params or query
  create(@Body(new ZodValidationPipe(createOrderSchema)) input: CreateOrderInput) {
    return { sku: input.sku, quantity: input.quantity };
  }
}

A minimal Zod PipeTransform that calls safeParse, returns the parsed data or throws a BadRequestException built from ZodError issues, attached to a single @Body() parameter.

How does it work end to end, from request to handler?

It helps to trace one request through the framework, because the order explains most of the surprises later in this article.

  1. Middleware runs first, then guards decide whether the request may proceed at all.
  2. Interceptors run their "before" logic.
  3. Pipes run for each handler argument. Nest applies them from the outside in: global pipes first, then controller-scoped pipes, then method-scoped pipes, and finally pipes bound to a specific parameter such as @Body(pipe).
  4. Each pipe receives the value returned by the previous one, plus an ArgumentMetadata object describing the argument: its source (body, query, param or custom), its metatype, and the property name if one was given to the decorator.
  5. The handler is called with whatever the last pipe returned. If any pipe throws, the handler never runs and the exception filter turns the error into a response, usually a 400.

The two libraries differ only in what happens inside step 3. ValidationPipe reads the metatype from ArgumentMetadata, builds a class instance and runs decorator-based checks. A Zod pipe ignores the metatype and runs the schema it was constructed with. Everything else, including guards, interceptors, exception filters and the order in which pipes run, is identical. That is why the two can coexist in one application, and why a global ValidationPipe sees a value before any parameter-scoped Zod pipe does.

Why choose Zod or class-validator for DTO validation: types, schemas and where they live

The two libraries answer one question differently: where does the canonical definition of a payload live?

With class-validator, the DTO class is both the TypeScript type and the runtime schema. Decorators such as @IsString() and @ValidateNested() attach rules to properties, and because the class exists at runtime, NestJS can reflect on it. That is why this approach fits the framework so well. The Swagger module can read the same class, instanceof checks work after transformation, and custom constraint classes can be resolved from Nest's DI container through class-validator's useContainer, so a validator can inject a repository. The cost is that the decorators and the property types are written separately and can disagree. A property typed number can carry @IsString(), or a non-optional field can be marked @IsOptional(), and the compiler accepts both.

Zod inverts this. The schema is the source, and the type is derived with z.infer<typeof schema>, so that kind of drift cannot occur. The derived type is erased at compile time, though. You no longer have a class for Nest to reflect on, OpenAPI generation needs extra wiring, and validators that depend on injected services have to be built differently.

What tips the decision is reuse. A Zod schema is a plain value with no dependency on decorators or reflect-metadata. The same module can validate environment config at boot, parse message-queue payloads in a worker, and ship in a shared package that a frontend imports. class-validator rules only exist where the decorated classes and their runtime metadata come along with them.

// orders.dto.ts: class-validator, where the type and the rules are written separately
import { IsInt, IsString } from 'class-validator';

export class CreateOrderDto {
  @IsString()
  sku!: string;

  // This compiles even though the rule and the type disagree.
  // At runtime the validator expects a string, but the type says number.
  @IsString()
  quantity!: number;
}

// orders.schema.ts: Zod, where the schema is the source and the type is derived
import { z } from 'zod';

export const createOrderSchema = z.object({
  sku: z.string().min(1),
  quantity: z.number().int().positive(),
});
export type CreateOrder = z.infer<typeof createOrderSchema>; // cannot drift

// The same plain value, with no decorators or reflect-metadata, reused in a queue worker
export function handleOrderMessage(raw: string): CreateOrder {
  return createOrderSchema.parse(JSON.parse(raw));
}

// The same pattern validates env config once at boot
export const envSchema = z.object({
  DATABASE_URL: z.string().min(1),
  PORT: z.coerce.number().int().default(3000),
});
export const env = envSchema.parse(process.env);

A class-validator DTO whose decorator contradicts its property type and still compiles, compared with a Zod schema that derives its type with z.infer and is reused unchanged for a queue payload and for boot-time env config.

How do coercion and nested objects behave differently in each library?

Query strings and route params always arrive as strings, so every number, date and boolean must be converted before it is validated. The two libraries handle this conversion in different places.

With ValidationPipe, setting transform: true together with enableImplicitConversion lets class-transformer convert values based on each property's emitted TypeScript type. You can also convert per field with @Type(() => Number). In Zod, you state the conversion in the schema with z.coerce.number(), z.coerce.date(), .transform() or z.preprocess().

Booleans are a trap in both libraries. Implicit conversion and z.coerce.boolean() both call JavaScript's Boolean(), so the string "false" becomes true. Parse the string explicitly in either library: use @Transform in class-transformer, or z.enum(["true", "false"]).transform(...) in Zod. A similar quirk affects numbers. Number("") is 0, so ?limit= turns into a valid zero unless you reject empty strings first.

Concern class-validator + class-transformer Zod
Numbers Implicit, or @Type(() => Number) z.coerce.number()
Dates @Type(() => Date) + @IsDate() z.coerce.date()
Booleans Needs @Transform Needs enum + transform or preprocess
Nested objects @ValidateNested() + @Type() z.object() composed inline
Arrays of objects @ValidateNested({ each: true }) + @Type() z.array(schema)

Nested DTOs are where class-validator fails silently. If @ValidateNested has no @Type, the nested value stays a plain object with no decorator metadata. Its inner rules never run. Depending on your forbidUnknownValues setting, you either get a vague error or nothing at all.

Zod schemas are ordinary values, so nesting one inside another always validates the inner schema. The trade-off is verbosity: every coercion in Zod is written out by hand. That is more typing, but each conversion is visible in code review. Implicit conversion is easier to write, but it can turn malformed input into plausible data without any error.

import { Type, Transform } from 'class-transformer';
import { IsArray, IsBoolean, IsDate, IsInt, IsString, Min, ValidateNested } from 'class-validator';
import { z } from 'zod';

// --- class-validator + class-transformer (needs ValidationPipe({ transform: true })) ---
class ItemDto {
  @IsString() sku!: string;
  @IsInt() @Min(1) qty!: number;
}

class ListOrdersQuery {
  @Type(() => Number) @IsInt() @Min(1) limit!: number;   // Number('') === 0, so Min(1) also rejects ?limit=
  @Type(() => Date) @IsDate() since!: Date;
  // Boolean('false') === true, so map the strings by hand; anything else fails @IsBoolean
  @Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
  @IsBoolean() archived!: boolean;
}

class CreateOrderDto {
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => ItemDto) // omit this and ItemDto's rules silently never run
  items!: ItemDto[];
}

// --- Zod: every conversion is written in the schema ---
const Item = z.object({ sku: z.string(), qty: z.number().int().min(1) });

const ListOrdersQuerySchema = z.object({
  // turn '' into undefined first so an empty param is rejected, not coerced to 0
  limit: z.preprocess((v) => (v === '' ? undefined : v), z.coerce.number().int().min(1)),
  since: z.coerce.date(),
  archived: z.enum(['true', 'false']).transform((v) => v === 'true'), // not z.coerce.boolean()
});

// nesting is just composition; no extra decorator to forget
const CreateOrderSchema = z.object({ items: z.array(Item) });

ListOrdersQuerySchema.parse({ limit: '20', since: '2026-01-01', archived: 'false' });
// => { limit: 20, since: Date, archived: false }

The same query and nested-body validation in both libraries, showing @Type/@Transform versus explicit Zod coercion, the 'false' boolean and empty-string number pitfalls, and the @Type that nested DTOs need to be validated at all.

How do you keep one consistent error format across class-validator and Zod?

Clients should not be able to tell which library rejected a request. Pick one error shape, for example a list of { path, code, message } entries with dot-joined paths such as items.2.sku, and make both validation paths produce it.

For class-validator, pass an exceptionFactory to ValidationPipe. It receives an array of ValidationError objects. Each one has a property, a constraints map of rule name to message, and children for nested DTOs. Errors on nested objects sit on the child nodes, not the parent, so the formatter has to walk the tree recursively and build each path along the way. Array elements show up as children whose property is the index.

Zod issues already carry a path array and a code, so in the Zod pipe you only join the path and map the code. Put both mappers in one shared module so the error contract has a single owner and one set of tests.

Async rules work differently in the two libraries. ValidateIf takes a synchronous condition, so it can only decide whether other rules run. Async checks, such as a uniqueness lookup against the database, belong in custom constraint classes. For those classes to receive injected services, you register Nest's container with class-validator, and ValidationPipe awaits them automatically.

Zod async refinements run only under parseAsync or safeParseAsync. A synchronous parse throws as soon as it hits an async refinement instead of running it. That means a pipe that uses the synchronous API breaks the first time someone adds a database check to a schema. Make the pipe's transform async and always call safeParseAsync. Also consider whether existence checks belong in validation at all. They hit the database inside the request path, and the row can still change before the handler runs, so the check does not guarantee the condition still holds when you use the data.

// validation-errors.ts: owns the API error contract for both validation paths
import { BadRequestException, PipeTransform, ValidationPipe } from '@nestjs/common';
import type { ValidationError } from 'class-validator';
import type { ZodType } from 'zod';

export type ApiError = { path: string; code: string; message: string };
const fail = (errors: ApiError[]) => new BadRequestException({ statusCode: 400, errors });

// Nested DTO errors live on child nodes, and array items appear as children
// whose property is the index, so walk the tree and build the path on the way.
export function fromClassValidator(errors: ValidationError[], parent = ''): ApiError[] {
  return errors.flatMap((e) => {
    const path = parent ? `${parent}.${e.property}` : e.property;
    const own = Object.entries(e.constraints ?? {}).map(([code, message]) => ({ path, code, message }));
    return [...own, ...fromClassValidator(e.children ?? [], path)];
  });
}

// Zod issues already carry a path array, so they only need to be joined.
// Normalise codes here too if clients must not see library-specific names.
export function fromZod(issues: { path: PropertyKey[]; code: string; message: string }[]): ApiError[] {
  return issues.map((i) => ({ path: i.path.map(String).join('.'), code: i.code, message: i.message }));
}

export const classValidatorPipe = new ValidationPipe({
  whitelist: true,
  transform: true,
  exceptionFactory: (errors) => fail(fromClassValidator(errors)),
});

// transform is async and always uses safeParseAsync, so a schema that later
// gains an async refinement (e.g. a DB lookup) keeps working instead of throwing.
export class ZodValidationPipe implements PipeTransform {
  constructor(private readonly schema: ZodType) {}

  async transform(value: unknown) {
    const result = await this.schema.safeParseAsync(value);
    if (!result.success) throw fail(fromZod(result.error.issues));
    return result.data;
  }
}

A shared module that turns nested class-validator ValidationError trees and Zod issue paths into one { path, code, message } list, wired into ValidationPipe's exceptionFactory and an async Zod pipe that always calls safeParseAsync.

Is Zod faster than class-validator in NestJS, and how should you measure it?

Published "X is N times faster" numbers rarely transfer to your service. Validation cost depends on payload shape (flat versus deeply nested, short versus long arrays), on schema features such as refinements, unions and transforms, and on library versions, since both projects have changed their internals over time. The two pipes also do different work. ValidationPipe usually runs class-transformer to build a class instance before class-validator walks its decorator metadata. A Zod pipe parses the plain object and returns a new one.

Measure in two steps. First, benchmark the pipe's transform method in isolation. Use your real DTOs, warm up the JIT, and include valid payloads, invalid payloads and your largest realistic payload, because building error objects has its own cost. Second, load-test the full endpoint with each pipe and compare p50 and p99 latency and CPU per request at the same throughput. The second step tells you whether the first one matters.

Component (illustrative estimate, assumed inputs) Time per request
Network and framework overhead 8 ms
Database query 12 ms
Validation, pipe A 0.05 ms
Validation, pipe B 0.20 ms

With these assumptions, the 0.15 ms gap is about 0.7% of a roughly 20 ms request. It is not a reason to switch.

Performance can legitimately decide the choice in a few cases:

  • Bulk endpoints that accept thousands of nested items per request.
  • High-throughput endpoints with little or no I/O, where CPU is the bottleneck.
  • Services already CPU-saturated, where validation shows up clearly in a profiler.

When should you use or avoid each approach, and what does switching cost?

If none of those apply, performance should not decide the choice. The deciding costs are API documentation, tooling and how your team reviews schemas.

The largest switching cost is OpenAPI. The NestJS Swagger module builds the spec from DTO class metadata and decorators. A Zod schema has no class for it to read. Moving to Zod leaves two options: keep parallel @ApiProperty decorators, which can drift from the schema, or adopt a schema-to-OpenAPI bridge. A bridge adds a dependency and its own edge cases around unions, transforms and refinements. Other tooling also assumes classes, including mapped types such as PartialType, code-first GraphQL and decorator-based serialization. Neither library helps with very large monolithic schemas. Big z.object trees and long decorated classes are equally hard to review, so split them by sub-resource whichever library you use.

Factor Favors class-validator Favors Zod
Team profile Comfortable with decorators, OOP-style NestJS Comfortable with functional TS, schema-first
OpenAPI needs Spec generated from DTOs, no bridge wanted Willing to adopt a bridge or spec-first workflow
Schema sharing Backend-only contracts Same schema reused in frontend, workers, scripts
Coercion style Implicit transform via class-transformer acceptable Explicit, per-field coercion preferred
Debugging risk Team knows transform/whitelist pitfalls Team wants inferred types to match runtime checks

There are three realistic paths:

  • Stay on class-validator when the Swagger output and DTO-based tooling work well.
  • Move fully to Zod for new services or ones with a spec-first workflow.
  • Run a hybrid: new or shared-contract endpoints use Zod, and the rest stay on DTOs. This works only if each route goes through exactly one validation path.

Who should use Zod pipes, and who should stay on class-validator?

Zod pipes are a good fit for:

  • Teams with a TypeScript monorepo or shared contracts package. If a frontend, a worker or a CLI already consumes the same payload types, one schema that both validates and produces the type removes a whole class of drift bugs.
  • New services without an existing DTO and Swagger investment. There is nothing to migrate, and you can choose an OpenAPI approach up front.
  • Services that validate more than HTTP input. Queue consumers, webhook receivers and boot-time config all benefit from the same schema library.
  • Teams that want coercion to be explicit and visible in review, particularly for query-heavy APIs where string-to-type conversion bugs are common.

class-validator remains the better fit for:

  • Established NestJS codebases whose OpenAPI spec is generated from DTOs and whose consumers depend on that spec.
  • Code-first GraphQL APIs and projects that rely on mapped types such as PartialType, PickType and OmitType, which operate on classes.
  • Validation that needs injected services, such as uniqueness checks against a repository, where custom constraint classes resolved from Nest's container already work.
  • Teams fluent in the decorator style that already know the @Type, whitelist and metadata pitfalls and have tests covering them.

If your team is in neither group clearly, the hybrid path from the previous section lets you try Zod on a few endpoints before committing.

What are the alternatives to Zod and class-validator?

Zod and class-validator are the most common choices in NestJS, but the pipe interface accepts any library that can validate a value and either return it or throw. The same per-parameter pattern shown above works for all of the following.

  • Joi. A schema-object library that predates Zod. The NestJS documentation has long used it as an example of a schema-based pipe. It validates well and has rich rules, but its TypeScript types are not inferred from the schema the way z.infer works, so you maintain types separately.
  • Yup. Similar in spirit to Zod, with schema-derived types. It is common in frontend form libraries, which makes it a candidate if your frontend already uses it and you want to share schemas.
  • Valibot. A schema library with an API similar to Zod's, built from small composable functions. It is aimed at keeping bundle size low when schemas are shipped to the browser, which matters more for shared frontend code than for a NestJS server.
  • JSON Schema with a validator such as Ajv. Schemas are plain JSON, so they are language-neutral and map directly onto OpenAPI, which can invert the Swagger problem: the spec becomes the source and validation is derived from it. Libraries such as TypeBox let you write JSON Schema in TypeScript with inferred types.
  • Compile-time generated validators. Some tools generate validation code from TypeScript types during the build. This removes the need to write a schema at all, but ties your build to a compiler plugin or transform step.
  • Hand-written validation. For a handful of simple endpoints, a type guard function inside a custom pipe is a legitimate choice. It stops being reasonable once you need nested objects, consistent error paths or coercion.

Whichever you pick, the concerns in this article still apply: how the library coerces strings, how it reports nested error paths, whether it supports async checks, and how you will generate API documentation from it.

How can you adopt Zod pipes incrementally without double validation?

Each route should go through exactly one validator per parameter. The global ValidationPipe runs before any parameter-scoped pipe, so a Zod pipe attached with @Body(new ZodPipe(schema)) sees whatever the global pipe already did to the value.

Keep the global pipe away from Zod parameters

ValidationPipe decides whether to act based on the parameter's metatype. When the metatype is a primitive or plain Object, the global pipe skips validation. A type inferred with z.infer is erased at compile time, so its runtime metatype is Object, and the global pipe leaves it alone.

Problems start when a Zod-validated parameter is typed with a class, such as a DTO class generated from a schema. The global pipe then treats it as a class-validator DTO. With whitelist enabled, it can strip every property, because none of them carry decorators. With forbidUnknownValues, it can reject the request outright.

There are two ways to prevent this:

  • Type Zod parameters with inferred types only.
  • Write a small ValidationPipe subclass that skips any metatype marked as schema-backed, for example through a static property or a metadata key.

One error shape

Have both paths throw the same exception type with the same payload: a list of { path, message, code } entries. Wire class-validator into that shape through exceptionFactory, and make the Zod pipe map ZodError issues into the same structure. Clients then cannot tell which library rejected their request, and you can move a route from one library to the other without breaking anyone.

The trade-off

Shared schemas reduce type drift between contracts, clients and handlers. That saves real engineering time on every contract change. It does not remove the need for tests that assert which payloads are accepted and which are rejected. Those tests are what prove a migrated route still behaves the same, whichever library sits behind it.

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 vs class-validator, 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 .env by 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.