A NestJS 12 upgrade lands, and a POST /users handler gets a small facelift: instead of a CreateUserDto class decorated with @IsString() and @IsInt(), there's a Zod schema and @Body({ schema: createUserSchema }). It looks like the new, cleaner way to do the exact same job. It compiles. It ships. And the first payload missing a required field sails straight through to the handler, undefined and all — no 400, no thrown exception, nothing in the logs. The schema was real. The validation never ran.
This is written against @nestjs/core 12.1.1 (verified 2026-09-29 via npm view @nestjs/core dist-tags; 12.0.0 shipped 2026-08-27, and 11.x is now on the legacy npm tag). Everything below — StandardSchemaValidationPipe, the schema option on @Body()/@Query()/@Param(), and the response-side StandardSchemaSerializerInterceptor — is new in the v12 line; it does not exist in v11. The behavior described here was read directly from the published @nestjs/common and @nestjs/core source for 12.1.1, not from a blog post about it.
What you'll learn
By the end of this article you'll be able to:
- Explain what actually happens when you attach
schemato@Body(),@Query(), or@Param()— and why nothing validates until a pipe is registered to read it - Wire up
StandardSchemaValidationPipecorrectly, globally or per-route, next to your existingValidationPipeusage - Predict when the pipe returns the schema's transformed value versus the original input, and control it with
transform - Read the exact shape of a Standard Schema validation error and customize it with
exceptionFactory - Decide, for a given route, whether a Standard Schema or a class-validator DTO is the better fit — and confirm the two can coexist in one app
Who this is for
You've written a NestJS controller and used class-validator DTOs with ValidationPipe at least once. If you haven't read the NestJS request lifecycle episode of this series, it's a useful map of exactly where a pipe runs relative to guards and interceptors — but this article is self-contained. Some familiarity with Zod or a similar schema library helps but isn't required; every example is explained inline.
Table of contents
- The problem: a schema that validates nothing
- The mental model: schema is metadata, the pipe is the reader
- Stage 1: registering StandardSchemaValidationPipe
- Stage 2: schema on @Body(), @Query(), and @Param()
- Stage 3: transform — coercion, not just checking
- Stage 4: reading and customizing validation errors
- Stage 5: the response side — StandardSchemaSerializerInterceptor
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
The problem: a schema that validates nothing
Here's the handler from the intro, in full:
import { Body, Controller, Post } from "@nestjs/common";
import { z } from "zod";
const createUserSchema = z.object({
name: z.string().min(1),
age: z.coerce.number().int().positive(),
});
@Controller("users")
export class UsersController {
@Post()
create(@Body({ schema: createUserSchema }) body: z.infer<typeof createUserSchema>) {
return { created: body };
}
}
And here's main.ts, unchanged from before the upgrade:
// main.ts — looks fine, nothing here reads `schema`
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(); // never called — no pipes registered at all
await app.listen(3000);
Send POST /users with {} — no name, no age — and the handler runs anyway, with body equal to {}. Nothing threw. @Body({ schema: createUserSchema }) reads like a self-contained instruction: "validate the body against this schema." It isn't one. schema is data attached to the parameter's metadata; something else has to notice it and act. In v11, that "something else" was ValidationPipe, and you had to register it. In v12, the equivalent for a Standard Schema is StandardSchemaValidationPipe, and the rule hasn't changed: register it, or the schema is just an inert object sitting on the metadata, doing nothing.
The mental model: schema is metadata, the pipe is the reader
The mental model: every parameter decorator that accepts { schema } — @Body(), @Query(), @Param(), @RawBody() — does exactly one thing with it: it attaches the schema object to that parameter's ArgumentMetadata, alongside the existing type ('body' | 'query' | 'param' | 'custom'), data, and metatype fields. Nest's router then runs every pipe configured for that parameter — global, controller-level, method-level, and param-level, in that order — and hands each one the same ArgumentMetadata, schema included. A pipe that has no idea schema exists (a hand-written ParseIntPipe, your own custom pipe, even ValidationPipe) just ignores the extra field and does its own thing. StandardSchemaValidationPipe is the one pipe in @nestjs/common that looks at metadata.schema and acts on it — and, like every other pipe, it only runs if you put it in the pipe chain.
That single fact explains the whole feature:
-
schemais a request for validation, not validation itself. Attaching it costs nothing at runtime unless a pipe reads it. -
StandardSchemaValidationPipedoes the actual work, by calling the schema's own~standard.validate()method — the one method every Standard Schema-compatible library (Zod, Valibot, ArkType, and others) implements. NestJS doesn't depend on any specific schema library to do this;@standard-schema/specis only a TypeScript type import in@nestjs/common, not a runtime dependency. -
The pipe's job ends at
transform(). It either returns a value (the request continues to the handler) or throws (the request stops, same as any other pipe failure — a filter turns it into an HTTP response).
Stage 1: registering StandardSchemaValidationPipe
The smallest version that actually validates something:
// main.ts
import { NestFactory } from "@nestjs/core";
import { StandardSchemaValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new StandardSchemaValidationPipe());
await app.listen(3000);
}
bootstrap();
Key concept: this is the exact same registration shape as app.useGlobalPipes(new ValidationPipe()) — because it's the exact same mechanism. StandardSchemaValidationPipe is a normal PipeTransform; nothing about schema makes it auto-register itself. You can also scope it to one controller or one route instead of the whole app, the same way you'd scope any pipe with @UsePipes(), if only part of your API has moved to Standard Schema.
With the pipe registered, the intro's POST /users with {} now behaves correctly: the handler never runs, and the caller gets a 400 (covered in Stage 4).
Stage 2: schema on @body(), @Query(), and @param()
The schema option is available on every parameter decorator that pulls data out of the request: @Body(), @Query(), @Param(), and @RawBody(). Attaching it is consistent across all of them — but whether StandardSchemaValidationPipe actually validates it by default isn't; see the @RawBody() gotcha below.
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { z } from "zod";
const createUserSchema = z.object({
name: z.string().min(1),
age: z.coerce.number().int().positive(),
});
const listUsersQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
role: z.enum(["admin", "editor", "viewer"]).optional(),
});
const idParamSchema = z.string().uuid();
@Controller("users")
export class UsersController {
@Post()
create(@Body({ schema: createUserSchema }) body: z.infer<typeof createUserSchema>) {
return { created: body };
}
@Get()
list(@Query({ schema: listUsersQuerySchema }) query: z.infer<typeof listUsersQuerySchema>) {
return { page: query.page, role: query.role ?? "all" };
}
@Get(":id")
findOne(@Param("id", { schema: idParamSchema }) id: string) {
return { id };
}
}
Key concept: @Param('id', { schema }) validates a single named parameter, the same way @Param('id', ParseUUIDPipe) always has; @Param({ schema }) with no property name validates the entire params object against a schema shaped like { id: string }. @Query() follows the identical pattern. There's no new mental model per decorator — it's the same { schema } option everywhere, because it's the same ArgumentMetadata.schema field everywhere.
Stage 3: transform — coercion, not just checking
By default, StandardSchemaValidationPipe doesn't just check that a value is valid — it replaces the value with whatever the schema produces. That matters because HTTP bodies and query strings arrive as strings and raw JSON, and a schema like z.coerce.number() in the listUsersQuerySchema above turns the string "2" into the number 2 before your handler ever sees it.
new StandardSchemaValidationPipe({
transform: true, // default — handler receives the schema's parsed/coerced output
});
new StandardSchemaValidationPipe({
transform: false, // handler receives the original, unmodified input
});
With the default transform: true, GET /users?page=2 gives your handler query.page === 2, a real number, not the string "2". Set transform: false and the pipe only checks that the value is valid — it hands the handler back the exact object it received, untouched. That's the right choice when a downstream pipe or your own code needs the raw shape, or when you're validating something you don't want silently rewritten (a raw file buffer, for instance).
Key concept: this mirrors what ValidationPipe's own transform option has always done for class-validator DTOs — coercion is opt-out, not opt-in, and it's easy to forget that a "42" in the request became a 42 by the time your handler logs it.
Stage 4: reading and customizing validation errors
When schema['~standard'].validate() returns issues instead of a value, StandardSchemaValidationPipe formats each one into a single string — the issue's path, joined with ., prefixed to its message — and throws an exception built from the full list.
Sending POST /users with { "name": "", "age": "not-a-number" } against the schema from Stage 1 produces something like:
{
"statusCode": 400,
"message": [
"name: String must contain at least 1 character(s)",
"age: Expected number, received nan"
],
"error": "Bad Request"
}
Both the HTTP status and the exception itself are configurable:
new StandardSchemaValidationPipe({
errorHttpStatusCode: 422, // default is 400 (Bad Request)
exceptionFactory: (issues) =>
new UnprocessableEntityException({
code: "VALIDATION_FAILED",
fields: issues.map((i) => ({ path: i.path, message: i.message })),
}),
});
Key concept: exceptionFactory receives the raw, unformatted issue list from the schema — not the joined strings — so you control the response shape completely. This is the same escape hatch ValidationPipe has always offered; only the shape of the input (Standard Schema issues instead of class-validator errors) is different.
Stage 5: the response side — StandardSchemaSerializerInterceptor
Validation on the way in has a counterpart on the way out. StandardSchemaSerializerInterceptor validates (and optionally transforms) what a handler returns, the same role ClassSerializerInterceptor plays for class-transformer-decorated classes:
import { Controller, Get, SerializeOptions, UseInterceptors } from "@nestjs/common";
import { StandardSchemaSerializerInterceptor } from "@nestjs/common";
import { z } from "zod";
const userResponseSchema = z.object({ id: z.string(), name: z.string() });
@UseInterceptors(StandardSchemaSerializerInterceptor)
@Controller("users")
export class UsersController {
@Get(":id")
@SerializeOptions({ schema: userResponseSchema })
findOne() {
return { id: "abc", name: "Ada", passwordHash: "…" }; // stripped down to the schema's shape
}
}
This article focuses on the request side, since that's where the "it looks validated but isn't" mistake bites — but it's worth knowing this exists so you're not reaching for ClassSerializerInterceptor out of habit on a route that already validates with a schema.
Edge cases and gotchas
-
A
schemawith no registered pipe is silent, not an error. This is the entire bug from the intro — no warning at boot, no lint rule, just metadata nobody reads. Always confirmStandardSchemaValidationPipeis actually in the chain for that route. -
Custom parameter decorators are skipped by default. A parameter built with
createParamDecorator()hasArgumentMetadata.type === 'custom', and the pipe skips those unless you passvalidateCustomDecorators: true. -
@RawBody({ schema })gets skipped by that same default, too. Internally,@nestjs/core'sParamsTokenFactory.exchangeEnumForString()only maps the body/query/param paramtypes to the strings'body','query', and'param'— every other paramtype,RAW_BODYincluded, falls through to'custom'. So a@RawBody({ schema })parameter reachesStandardSchemaValidationPipewithmetadata.type === 'custom'and is silently skipped unless the pipe is constructed withvalidateCustomDecorators: true— the exact "attached but never read" failure this article opens with, just on a decorator that looks like it should behave like@Body(). -
class-validatorandclass-transformerare now optional peer dependencies —@nestjs/common's ownpackage.jsonlists both underpeerDependenciesMetawithoptional: true. A project fully on Standard Schema doesn't need either installed. -
The two approaches coexist without conflict. Both are ordinary pipes reading different parts of the same
ArgumentMetadata; registering both globally is safe, since a class-based DTO parameter has noschemaand a Standard Schema parameter has no classmetatype. -
Values are sanitized before validation. The pipe strips dangerous prototype-pollution keys (
__proto__and friends) from the input before handing it to the schema.
Best practices
-
Register
StandardSchemaValidationPipeglobally once, the same way you'd registerValidationPipe— don't scatter@UsePipes()per route unless it genuinely needs different options. -
Reach for a Standard Schema library when validation logic is shared outside NestJS — a Zod schema also used on a frontend form, for instance — since the schema itself is portable in a way a
class-validatorDTO class isn't. -
Keep
class-validatorDTOs where decorators already carry the app's conventions (Swagger@ApiProperty(), serialization groups). Migrate per-module, not per-app. -
Set
transform: falseexplicitly when the raw input matters elsewhere in the pipe chain — don't assume the default coercion is a no-op. -
Write a custom
exceptionFactoryonce, at the global registration, if your API has a house error shape.
FAQ
Does Standard Schema validation replace ValidationPipe and class-validator?
No. Both ship in @nestjs/common 12.x. class-validator and class-transformer moved to optional peer dependencies, so a Standard-Schema-only project can drop them — but ValidationPipe itself hasn't been deprecated.
Do I have to install Zod for this to work?
No. @nestjs/common only imports @standard-schema/spec as a TypeScript type, not as a runtime dependency, so it has no opinion on which schema library you use. Any library implementing the Standard Schema spec — Zod, Valibot, and ArkType are the best-known ones — works with { schema } the same way.
Why does @Body({ schema }) compile and run but never validate anything?
Almost always because StandardSchemaValidationPipe isn't in the pipe chain for that route — see Stage 1. schema is metadata the decorator attaches; nothing enforces it by itself.
Can I use schema with a custom decorator built from createParamDecorator()?
Only if you construct the pipe with validateCustomDecorators: true. By default, StandardSchemaValidationPipe treats any parameter of type 'custom' as out of scope, since custom decorators can return arbitrary shapes the author may not intend to run through a schema at all.
What does a nested validation error look like?
Each issue's path array is joined with . and prefixed to its message — a failure on address.zip in a nested object becomes the string "address.zip: Invalid input" in the default error list. Pass your own exceptionFactory if you need the raw, unflattened path array instead of the joined string.
Cheat sheet
| Task | Code | Notes |
|---|---|---|
| Attach a schema to a param | @Body({ schema: userSchema }) |
Also works on @Query(), @Param(), @RawBody() — but @RawBody() needs validateCustomDecorators: true to actually validate |
| Register the pipe globally | app.useGlobalPipes(new StandardSchemaValidationPipe()) |
Required — schema alone validates nothing |
| Validate a single named param | @Param('id', { schema: idSchema }) |
Validates just that property |
| Validate the whole params object | @Param({ schema: paramsSchema }) |
No property name argument |
| Keep the raw input (no coercion) | new StandardSchemaValidationPipe({ transform: false }) |
Default is transform: true
|
| Custom error shape/status | new StandardSchemaValidationPipe({ exceptionFactory, errorHttpStatusCode }) |
exceptionFactory receives raw, unformatted issues |
Validate a createParamDecorator() value |
new StandardSchemaValidationPipe({ validateCustomDecorators: true }) |
Default is false — custom decorators are skipped |
| Validate the response instead of the request |
@UseInterceptors(StandardSchemaSerializerInterceptor) + @SerializeOptions({ schema })
|
The output-side counterpart |
// main.ts
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new StandardSchemaValidationPipe()); // required — schema is inert without this
// users.controller.ts
const createUserSchema = z.object({
name: z.string().min(1),
age: z.coerce.number().int().positive(),
});
@Post()
create(@Body({ schema: createUserSchema }) body: z.infer<typeof createUserSchema>) {
return { created: body }; // body.age is a number, coerced from the JSON string
}
Key takeaways
-
{ schema }on@Body(),@Query(),@Param(), and@RawBody()only attaches metadata — it does nothing until a pipe reads it, exactly like a class-based DTO does nothing withoutValidationPipe. -
StandardSchemaValidationPipe, new in@nestjs/core12.x, is that pipe, and it must be registered globally or per-route yourself. -
transformdefaults totrue: the handler gets the schema's parsed/coerced output, not necessarily the original request value. - Any Standard Schema-compatible library works — NestJS depends on the spec, not on Zod specifically — and
class-validator/class-transformerare now optional, so the two validation styles can run side by side or replace each other module by module. -
StandardSchemaSerializerInterceptoris the matching piece for outgoing responses, the same roleClassSerializerInterceptorplays today.
Ending
The bug in the intro wasn't a broken schema or a NestJS defect — it was a decorator that reads like an instruction and is actually a label. { schema: createUserSchema } tells Nest "here is a schema, should anyone ask" — it doesn't tell Nest to ask. That's the same shape of mistake new RolesGuard(...) outside the DI container makes, or a ValidationPipe nobody registered: a piece that looks complete on its own only works because something else, wired up separately, is watching for it. Once StandardSchemaValidationPipe is in the chain, the rest — coercion, custom error shapes, response-side serialization — is just configuration on a pipe you already understand.
Have you moved a route to Standard Schema validation yet, or are you holding the line with class-validator for now? Drop your reasoning in the comments.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
📚 Read next
- NestJS Testing Module: Provider Overrides (with Cheat Sheet)
- NestJS Guards: CanActivate, ExecutionContext & Reflector
- NestJS Request Lifecycle Explained (with Cheat Sheet)
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)