Paul Wade.dev
NOW READING · NESTJS ZOD STANDARD SCHEMA PIPE
All posts

Zod in NestJS without a custom pipe: StandardSchemaValidationPipe as the gate

Obstacle 76 min read

NestJS 12 validates Zod bodies with a built-in pipe. Wire it next to ValidationPipe, migrate one route at a time, and know what the handler receives.

On NestJS 12, register `StandardSchemaValidationPipe` globally and pass the Zod schema to the decorator: `@Body({ schema: TransferSchema })`. No custom pipe. A body that fails the schema gets a 400 before the handler runs. The handler receives the schema's output, so your service sees coerced numbers, applied defaults and no unknown keys.

The pipe ships in @nestjs/common 12.0.0. On Nest 11 you still need your own pipe, like the ZodPipe in validation-is-a-gate.

The short answer

Schema (verified in a small repro on Nest 12.1.2 and Zod 4.6.5):

typescript
import { z } from 'zod';

export const TransferSchema = z.object({
  fromId: z.uuid(),
  toId: z.uuid(),
  amount: z.coerce.number().int().positive(),
  memo: z.string().trim().default(''),
});

export type TransferInput = z.infer<typeof TransferSchema>;

Bootstrap:

typescript
app.useGlobalPipes(new StandardSchemaValidationPipe());

Controller:

typescript
@Post('transfer')
transfer(@Body({ schema: TransferSchema }) dto: TransferInput) {
  return this.payments.transfer(dto);
}

The decorator only attaches the schema. The pipe does the refusing. A schema on @Body with no pipe registered validates nothing.

What replaces the ZodPipe

validation-is-a-gate showed a hand-written ZodPipe with safeParse and BadRequestException. On v12 that class goes away. The schema moves into the decorator.

Old:

typescript
@Body(new ZodPipe(TransferSchema)) dto: TransferInput

New:

typescript
@Body({ schema: TransferSchema }) dto: TransferInput

The pipe takes any Standard Schema library (Zod, Valibot, ArkType). This post stays on Zod.

The schema option works on @Body(), @Query(), @Param() and @RawBody(), plus @MessageBody() in gateways and @Payload() in microservices.

Default failure body, from the same small repro:

json
{
  "message": [
    "fromId: Invalid UUID",
    "toId: Invalid input: expected string, received undefined",
    "amount: Too small: expected number to be >0"
  ],
  "error": "Bad Request",
  "statusCode": 400
}

Need a stable error shape? Pass exceptionFactory. It gets the issue list. See the NestJS validation docs.

Run both pipes and move one route at a time

Register both globally:

typescript
app.useGlobalPipes(
  new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
  new StandardSchemaValidationPipe(),
);

ValidationPipe validates parameters typed with a class. StandardSchemaValidationPipe validates parameters that declare a schema. Everything else passes through each pipe untouched. A z.infer type reflects as Object at runtime, so ValidationPipe leaves those parameters alone. class-validator stays supported; the v12 release notes say there is no plan to remove it. The shipped API came from PR #16373.

Migration path: pick one route. Write the schema. Change the parameter to @Body({ schema }) with the z.infer type. Delete the DTO class when nothing else imports it. Next route. You keep the old ValidationPipe routes working while schema routes come online. No big-bang cutover.

The one trap, from the repro. Type a schema parameter with a class (dto: TransferDto) and ValidationPipe runs on it too. With forbidNonWhitelisted and no decorators on that class, every property comes back as "property fromId should not exist". The schema may have already passed. Then the class pipe rejects the same fields. Type schema parameters with z.infer<typeof Schema>, not a class.

Don't mix both on one parameter. One parameter, one gate.

The handler gets the schema's output

Default is transform: true. The handler gets what the schema produced, not what the client sent. Coercion, trim, defaults and stripped keys are already applied before your method runs.

Same request body to the same route, both pipes global, from the small repro. UUIDs truncated with ellipsis:

json
{ "fromId": "6f1c…", "toId": "0b8e…", "amount": "42", "memo": "  rent  ", "role": "admin" }
transform: true (default)transform: false
amount42 (number)"42" (string)
memo"rent"" rent "
rolegone"admin"

Read it plainly. With transform: false the request passed validation and the handler still got a string where the type says number, plus a role key nobody declared. z.infer describes the output, so the type is now wrong at runtime. That is guidance again, not a gate. The service can still see junk that the schema "accepted" only as input shape.

This matches the old ZodPipe. It returned parsed.data, the output. The built-in pipe keeps that default. Don't flip it globally just because you want to "see the raw body." Log the raw body in middleware if you need it. Keep the handler on the parsed value.

If you do set transform: false on a route, type the parameter with z.input<typeof Schema>. That's what the docs say. Use z.strictObject() there so extra keys fail instead of passing through. Otherwise role: "admin" walks into the method after a green 200 path through the pipe.

Unknown keys are the schema's call, not the pipe's:

  • z.object() strips them. Same as whitelist.
  • z.strictObject() rejects them with Unrecognized key: "role". Same as forbidNonWhitelisted.
  • z.looseObject() keeps them. Don't use it at the HTTP edge.

In validation-is-a-gate, forbidNonWhitelisted turned extras into 400s. The Zod equivalent is z.strictObject(). Payment-shaped routes get the strict version. Silent strip is fine when extras are noise. Refuse when an extra key could change meaning.

Output also means defaults and transforms are business-visible. memo defaulting to '' is now a value your service stores. Put defaults in the schema only when the service should treat them as sent. A missing amount that becomes 0 through a soft default is inventing a transfer. Prefer a 400.

Params, query strings and custom decorators

Params and query values arrive as strings. Use z.coerce.number() in the schema. Don't turn on implicit conversion elsewhere.

typescript
@Get(':id')
findOne(@Param('id', { schema: z.coerce.number().int().positive() }) id: number) {
  return this.payments.findOne(id);
}

Validate the whole query object with one schema (@Query({ schema: listQuerySchema })). Defaults for page and limit live there.

Custom decorators made with createParamDecorator() accept schema too, but the pipe skips them unless validateCustomDecorators: true. A tenant-id header decorator with a schema and no flag is not validated.

Checklist

  • StandardSchemaValidationPipe registered on every app bootstrap that has schema routes
  • transform left at its default (true)
  • Schema params typed with z.infer, never with a class
  • z.strictObject() on bodies where an extra key could change meaning
  • Coercion in the schema for params and query, not in the service
  • validateCustomDecorators: true if any custom decorator carries a schema
  • Services take the inferred type or a command. They don't import Zod to re-check.

The schema is the DTO now; the boundary rules don't change. Brand ids after the pipe, in the controller mapper: types that stop at the wall.

FAQ

How do I validate a NestJS request body with Zod? On NestJS 12, register StandardSchemaValidationPipe with app.useGlobalPipes() and pass the schema to the decorator: @Body({ schema: TransferSchema }). Bad bodies get a 400 before the handler runs. Type the parameter with z.infer<typeof TransferSchema>. On Nest 11 you still write your own pipe.

Can ValidationPipe and StandardSchemaValidationPipe run in the same app? Register both globally. ValidationPipe checks class-typed parameters and the schema pipe checks parameters with a schema option, so you move routes over one at a time. Type schema parameters with z.infer, not a class, or ValidationPipe validates them too.

Why does my handler get different data than the client sent? transform defaults to true, so the handler receives the schema's output. Coercion, .trim(), defaults and stripped keys are already applied. That's the gate working. Setting transform: false passes the raw body through, including keys the schema never declared.