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 (0)