DEV Community

Cover image for NestJS 12: Standard Schema Validation Without class-validator
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

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.

What you'll learn

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

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

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. schema is a request for validation, not validation itself. Attaching it costs nothing at runtime unless a pipe reads it.
  2. 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.
  3. 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();
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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 })),
    }),
});
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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 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.

Best practices

  • 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.

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
}
Enter fullscreen mode Exit fullscreen mode

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 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.

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


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

Top comments (1)

Collapse
 
koev3kcjausd profile image
koev3kcjausd •

NestJS 12 này là bước tiến lớn cho team mình — cuối cùng cũng thoát được bộ triple decorators @IsString() @IsOptional() @MinLength(8) lặp lại ở mỗi DTO. Zod schema định nghĩa một chỗ, export type tự động, FE/BE share types qua monorepo không lo lệch phase.

Một điểm lưu ý: ValidationPipe mặc định vẫn transform class-validator style (whitelist, forbidNonWhitelisted). Khi chuyển sang Standard Schema, nhớ tắt transform: true hoặc custom pipe nếu không muốn Nest tự cast string → number/boolean từ query params — Zod đã handle coercion ở layer schema rồi, double transform dễ gây bug lạ (ví dụ "false" thành false thay vì string).

Performance wise, benchmark nhanh trên route 10k req/s: Zod parse nhanh hơn class-validator + class-transformer combo khoảng 15-20% vì bỏ qua reflection metadata. Valibot thì nhẹ hơn bundle size nhưng API hơi khác, team mình chọn Zod cho consistency với frontend.

Cheat sheet trong bài hữu ích, nhưng thiếu case nested object validation — @Body() data: CreateUserDto với Zod z.object({ profile: z.object({...}) }) thì pipe validate đệ quy ngon lành, không cần `@ PS: the tool I meant is on labagent .tech