NestJS 12: Standard Schema Validation Without class-validator
NestJS 12 adds Standard Schema validation for @Body(), @Query(), and @Param() — validate with Zod or Valibot, no class-validator DTO. Cheat sheet included.

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.
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
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.
- 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
Here's the handler from the intro, in full:
And here's main.ts, unchanged from before the upgrade:
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: 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).
The smallest version that actually validates something:
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).
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.
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.
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.
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.
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:
Both the HTTP status and the exception itself are configurable:
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.
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:
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.
- 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.
- 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.
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.
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.
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.
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.
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.
| 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 |
{ 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.
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.
Runs right in your browser — poke at it and watch the concept react live.
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
- 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___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.