← blog
NestjsOctober 2, 2026 · 14 min read

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.

Parsa Jiravand · Frontend engineer · building bestpractic
NestJS 12: Standard Schema Validation Without class-validator

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 schema to @Body(), @Query(), or @Param() — and why nothing validates until a pipe is registered to read it
  • Wire up StandardSchemaValidationPipe correctly, globally or per-route, next to your existing ValidationPipe usage
  • 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.

Here's the handler from the intro, in full:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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:

TypeScript
1
2
3
4
// 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: 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:

  • schema is a request for validation, not validation itself. Attaching it costs nothing at runtime unless a pipe reads it.
  • StandardSchemaValidationPipe does 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/spec is 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:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
// 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).

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.

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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.

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.

TypeScript
1
2
3
4
5
6
7
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.

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:

JSON
1
2
3
4
5
6
7
8
{ "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:

TypeScript
1
2
3
4
5
6
7
8
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.

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:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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.

  • A schema with 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 confirm StandardSchemaValidationPipe is actually in the chain for that route.
  • Custom parameter decorators are skipped by default. A parameter built with createParamDecorator() has ArgumentMetadata.type === 'custom', and the pipe skips those unless you pass validateCustomDecorators: true.
  • @RawBody({ schema }) gets skipped by that same default, too. Internally, @nestjs/core's ParamsTokenFactory.exchangeEnumForString() only maps the body/query/param paramtypes to the strings 'body', 'query', and 'param' — every other paramtype, RAW_BODY included, falls through to 'custom'. So a @RawBody({ schema }) parameter reaches StandardSchemaValidationPipe with metadata.type === 'custom' and is silently skipped unless the pipe is constructed with validateCustomDecorators: 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-validator and class-transformer are now optional peer dependencies — @nestjs/common's own package.json lists both under peerDependenciesMeta with optional: 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 no schema and a Standard Schema parameter has no class metatype.
  • 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 StandardSchemaValidationPipe globally once, the same way you'd register ValidationPipe — 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-validator DTO class isn't.
  • Keep class-validator DTOs where decorators already carry the app's conventions (Swagger @ApiProperty(), serialization groups). Migrate per-module, not per-app.
  • Set transform: false explicitly when the raw input matters elsewhere in the pipe chain — don't assume the default coercion is a no-op.
  • Write a custom exceptionFactory once, 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.

TaskCodeNotes
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 globallyapp.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/statusnew StandardSchemaValidationPipe({ exceptionFactory, errorHttpStatusCode })exceptionFactory receives raw, unformatted issues
Validate a createParamDecorator() valuenew 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
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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 }

  • { 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 without ValidationPipe.
  • StandardSchemaValidationPipe, new in @nestjs/core 12.x, is that pipe, and it must be registered globally or per-route yourself.
  • transform defaults to true: 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-transformer are now optional, so the two validation styles can run side by side or replace each other module by module.
  • StandardSchemaSerializerInterceptor is the matching piece for outgoing responses, the same role ClassSerializerInterceptor plays 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.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 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:

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.