[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"quiz-nestjs-weekly-standard-schema-validation":3,"verticals":25,"quiz-article-nestjs-weekly-standard-schema-validation":60,"search-suggestions":485},{"id":4,"slug":5,"kind":6,"title":7,"description":8,"config":9,"verticalId":17,"vertical":18,"course":11,"_count":21,"access":22,"attempts":24,"questionCount":10},"01a0f0ef-cd7f-75f8-84f7-642c3e25dcf9","nestjs-weekly-standard-schema-validation","PRACTICE_QUIZ","NestJS 12: Standard Schema Validation Without class-validator","Test what you learned about NestJS 12's Standard Schema validation — what @Body({ schema }) actually attaches, why StandardSchemaValidationPipe must be registered, transform's default coercion, and how validation errors are shaped.",{"questionCount":10,"timeLimitSec":11,"shuffleQuestions":12,"shuffleOptions":13,"negativeMarking":14,"passScorePct":15,"maxAttempts":11,"revealAnswers":16,"allowFlagging":12,"allowBacktracking":13},8,null,false,true,0,70,"AFTER_SUBMIT","019fe637-3d33-714b-b57f-23e163ffca0c",{"slug":19,"name":20},"dev","Web Development",{"questions":10},{"allowed":13,"reason":23},"FREE",[],[26,36,48],{"id":17,"slug":19,"name":20,"tagline":27,"description":28,"accentFrom":29,"accentTo":30,"icon":31,"defaultLocale":32,"locales":33,"features":35,"position":14},"Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[32,34],"fa",{"courses":13,"paths":13,"articles":13,"exams":12,"flashcards":12,"packages":13,"community":13,"certificates":13,"teams":13,"commerce":13},{"id":37,"slug":38,"name":39,"tagline":40,"description":41,"accentFrom":42,"accentTo":29,"icon":43,"defaultLocale":32,"locales":44,"features":46,"position":47},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[32,34,45],"es",{"courses":12,"paths":12,"articles":13,"exams":12,"flashcards":13,"packages":12,"community":13,"certificates":13,"teams":12,"commerce":12},2,{"id":49,"slug":50,"name":51,"tagline":52,"description":53,"accentFrom":54,"accentTo":55,"icon":56,"defaultLocale":32,"locales":57,"features":58,"position":59},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[32],{"courses":12,"paths":12,"articles":13,"exams":12,"flashcards":12,"packages":12,"community":13,"certificates":12,"teams":12,"commerce":12},3,{"id":61,"slug":5,"title":7,"subtitle":11,"excerpt":62,"coverUrl":63,"locale":32,"readingMinutes":64,"publishedAt":65,"viewCount":66,"likeCount":14,"commentCount":14,"author":67,"vertical":72,"topic":73,"tags":76,"_count":87,"playground":89,"body":91,"bodyMd":445,"seo":446,"translationGroupId":448,"series":449,"podcastUrl":11,"verticalId":17,"thread":476,"assessments":478,"translations":481,"quiz":483},"01a0f0ef-ccc8-73e8-9b53-e6ce4c7fe9ff","NestJS 12 adds Standard Schema validation for @Body(), @Query(), and @Param() — validate with Zod or Valibot, no class-validator DTO. Cheat sheet included.","\u002Fmedia\u002Fcovers\u002Fnestjs-weekly-standard-schema-validation.png",14,"2026-10-02T06:11:02.679Z",50,{"id":68,"name":69,"username":70,"avatarUrl":11,"headline":71},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":19,"name":20,"accentFrom":29,"accentTo":30},{"slug":74,"name":75},"nestjs","Nestjs",[77,78,81,84],{"slug":74,"name":75,"color":11},{"slug":79,"name":80,"color":11},"node","Node",{"slug":82,"name":83,"color":11},"typescript","Typescript",{"slug":85,"name":86,"color":11},"tutorial","Tutorial",{"assessments":88},1,{"slug":5,"title":90},"NestJS Standard Schema validation — pipe playground",{"blocks":92,"version":88},[93,97,100,105,108,117,120,123,126,141,144,147,153,156,160,163,166,169,172,178,181,184,188,191,194,197,200,204,207,210,213,217,220,223,226,229,232,237,240,244,247,250,253,257,260,263,272,275,283,286,289,292,295,298,302,305,309,312,315,318,321,361,365,368,376,379,382,385,388,391,394,397,400,403,406,409,412,415,418,421,427,430,433,436,439],{"id":94,"html":95,"type":96},"b1","\u003Cp>A NestJS 12 upgrade lands, and a \u003Ccode>POST \u002Fusers\u003C\u002Fcode> handler gets a small facelift: instead of a \u003Ccode>CreateUserDto\u003C\u002Fcode> class decorated with \u003Ccode>@IsString()\u003C\u002Fcode> and \u003Ccode>@IsInt()\u003C\u002Fcode>, there&#39;s a Zod schema and \u003Ccode>@Body({ schema: createUserSchema })\u003C\u002Fcode>. 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, \u003Ccode>undefined\u003C\u002Fcode> and all — no \u003Ccode>400\u003C\u002Fcode>, no thrown exception, nothing in the logs. The schema was real. The validation never ran.\u003C\u002Fp>","paragraph",{"id":98,"html":99,"type":96},"b2","\u003Cp>This is written against \u003Cstrong>\u003Ccode>@nestjs\u002Fcore\u003C\u002Fcode> 12.1.1\u003C\u002Fstrong> (verified 2026-09-29 via \u003Ccode>npm view @nestjs\u002Fcore dist-tags\u003C\u002Fcode>; \u003Ccode>12.0.0\u003C\u002Fcode> shipped 2026-08-27, and \u003Ccode>11.x\u003C\u002Fcode> is now on the \u003Ccode>legacy\u003C\u002Fcode> npm tag). Everything below — \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode>, the \u003Ccode>schema\u003C\u002Fcode> option on \u003Ccode>@Body()\u003C\u002Fcode>\u002F\u003Ccode>@Query()\u003C\u002Fcode>\u002F\u003Ccode>@Param()\u003C\u002Fcode>, and the response-side \u003Ccode>StandardSchemaSerializerInterceptor\u003C\u002Fcode> — is new in the v12 line; it does not exist in v11. The behavior described here was read directly from the published \u003Ccode>@nestjs\u002Fcommon\u003C\u002Fcode> and \u003Ccode>@nestjs\u002Fcore\u003C\u002Fcode> source for \u003Ccode>12.1.1\u003C\u002Fcode>, not from a blog post about it.\u003C\u002Fp>",{"id":101,"html":102,"text":103,"type":104,"level":47},"b3","What you&#39;ll learn","What you'll learn","heading",{"id":106,"html":107,"type":96},"b4","\u003Cp>By the end of this article you&#39;ll be able to:\u003C\u002Fp>",{"id":109,"type":110,"items":111,"ordered":12},"b5","list",[112,113,114,115,116],"Explain what actually happens when you attach \u003Ccode>schema\u003C\u002Fcode> to \u003Ccode>@Body()\u003C\u002Fcode>, \u003Ccode>@Query()\u003C\u002Fcode>, or \u003Ccode>@Param()\u003C\u002Fcode> — and why nothing validates until a pipe is registered to read it","Wire up \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode> correctly, globally or per-route, next to your existing \u003Ccode>ValidationPipe\u003C\u002Fcode> usage","Predict when the pipe returns the schema&#39;s \u003Cem>transformed\u003C\u002Fem> value versus the original input, and control it with \u003Ccode>transform\u003C\u002Fcode>","Read the exact shape of a Standard Schema validation error and customize it with \u003Ccode>exceptionFactory\u003C\u002Fcode>","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",{"id":118,"html":119,"text":119,"type":104,"level":47},"b6","Who this is for",{"id":121,"html":122,"type":96},"b7","\u003Cp>You&#39;ve written a NestJS controller and used \u003Ccode>class-validator\u003C\u002Fcode> DTOs with \u003Ccode>ValidationPipe\u003C\u002Fcode> at least once. If you haven&#39;t read the \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fnestjs-request-lifecycle-explained-with-cheat-sheet-227l\">NestJS request lifecycle\u003C\u002Fa> episode of this series, it&#39;s a useful map of exactly where a pipe runs relative to guards and interceptors — but this article is self-contained. Some familiarity with \u003Ca href=\"https:\u002F\u002Fzod.dev\">Zod\u003C\u002Fa> or a similar schema library helps but isn&#39;t required; every example is explained inline.\u003C\u002Fp>",{"id":124,"html":125,"text":125,"type":104,"level":47},"b8","Table of contents",{"id":127,"type":110,"items":128,"ordered":12},"b9",[129,130,131,132,133,134,135,136,137,138,139,140],"\u003Ca href=\"#the-problem-a-schema-that-validates-nothing\">The problem: a schema that validates nothing\u003C\u002Fa>","\u003Ca href=\"#the-mental-model-schema-is-metadata-the-pipe-is-the-reader\">The mental model: schema is metadata, the pipe is the reader\u003C\u002Fa>","\u003Ca href=\"#stage-1-registering-standardschemavalidationpipe\">Stage 1: registering StandardSchemaValidationPipe\u003C\u002Fa>","\u003Ca href=\"#stage-2-schema-on-body-query-and-param\">Stage 2: schema on @Body(), @Query(), and @Param()\u003C\u002Fa>","\u003Ca href=\"#stage-3-transform--coercion-not-just-checking\">Stage 3: transform — coercion, not just checking\u003C\u002Fa>","\u003Ca href=\"#stage-4-reading-and-customizing-validation-errors\">Stage 4: reading and customizing validation errors\u003C\u002Fa>","\u003Ca href=\"#stage-5-the-response-side--standardschemaserializerinterceptor\">Stage 5: the response side — StandardSchemaSerializerInterceptor\u003C\u002Fa>","\u003Ca href=\"#edge-cases-and-gotchas\">Edge cases and gotchas\u003C\u002Fa>","\u003Ca href=\"#best-practices\">Best practices\u003C\u002Fa>","\u003Ca href=\"#faq\">FAQ\u003C\u002Fa>","\u003Ca href=\"#cheat-sheet\">Cheat sheet\u003C\u002Fa>","\u003Ca href=\"#key-takeaways\">Key takeaways\u003C\u002Fa>",{"id":142,"html":143,"text":143,"type":104,"level":47},"b10","The problem: a schema that validates nothing",{"id":145,"html":146,"type":96},"b11","\u003Cp>Here&#39;s the handler from the intro, in full:\u003C\u002Fp>",{"id":148,"code":149,"type":150,"language":151,"highlight":152},"b12","import { Body, Controller, Post } from \"@nestjs\u002Fcommon\";\nimport { z } from \"zod\";\n\nconst createUserSchema = z.object({\n  name: z.string().min(1),\n  age: z.coerce.number().int().positive(),\n});\n\n@Controller(\"users\")\nexport class UsersController {\n  @Post()\n  create(@Body({ schema: createUserSchema }) body: z.infer\u003Ctypeof createUserSchema>) {\n    return { created: body };\n  }\n}","code","ts",[],{"id":154,"html":155,"type":96},"b13","\u003Cp>And here&#39;s \u003Ccode>main.ts\u003C\u002Fcode>, unchanged from before the upgrade:\u003C\u002Fp>",{"id":157,"code":158,"type":150,"language":151,"highlight":159},"b14","\u002F\u002F main.ts — looks fine, nothing here reads `schema`\nconst app = await NestFactory.create(AppModule);\napp.useGlobalPipes(); \u002F\u002F never called — no pipes registered at all\nawait app.listen(3000);",[],{"id":161,"html":162,"type":96},"b15","\u003Cp>Send \u003Ccode>POST \u002Fusers\u003C\u002Fcode> with \u003Ccode>{}\u003C\u002Fcode> — no \u003Ccode>name\u003C\u002Fcode>, no \u003Ccode>age\u003C\u002Fcode> — and the handler runs anyway, with \u003Ccode>body\u003C\u002Fcode> equal to \u003Ccode>{}\u003C\u002Fcode>. Nothing threw. \u003Ccode>@Body({ schema: createUserSchema })\u003C\u002Fcode> reads like a self-contained instruction: &quot;validate the body against this schema.&quot; It isn&#39;t one. \u003Ccode>schema\u003C\u002Fcode> is data attached to the parameter&#39;s metadata; something else has to notice it and act. In v11, that &quot;something else&quot; was \u003Ccode>ValidationPipe\u003C\u002Fcode>, and you had to register it. In v12, the equivalent for a Standard Schema is \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode>, and the rule hasn&#39;t changed: register it, or the schema is just an inert object sitting on the metadata, doing nothing.\u003C\u002Fp>",{"id":164,"html":165,"text":165,"type":104,"level":47},"b16","The mental model: schema is metadata, the pipe is the reader",{"id":167,"html":168,"type":96},"b17","\u003Cp>\u003Cstrong>The mental model:\u003C\u002Fstrong> every parameter decorator that accepts \u003Ccode>{ schema }\u003C\u002Fcode> — \u003Ccode>@Body()\u003C\u002Fcode>, \u003Ccode>@Query()\u003C\u002Fcode>, \u003Ccode>@Param()\u003C\u002Fcode>, \u003Ccode>@RawBody()\u003C\u002Fcode> — does exactly one thing with it: it attaches the schema object to that parameter&#39;s \u003Ccode>ArgumentMetadata\u003C\u002Fcode>, alongside the existing \u003Ccode>type\u003C\u002Fcode> (\u003Ccode>&#39;body&#39; | &#39;query&#39; | &#39;param&#39; | &#39;custom&#39;\u003C\u002Fcode>), \u003Ccode>data\u003C\u002Fcode>, and \u003Ccode>metatype\u003C\u002Fcode> fields. Nest&#39;s router then runs \u003Cem>every pipe configured for that parameter\u003C\u002Fem> — global, controller-level, method-level, and param-level, in that order — and hands each one the same \u003Ccode>ArgumentMetadata\u003C\u002Fcode>, \u003Ccode>schema\u003C\u002Fcode> included. A pipe that has no idea \u003Ccode>schema\u003C\u002Fcode> exists (a hand-written \u003Ccode>ParseIntPipe\u003C\u002Fcode>, your own custom pipe, even \u003Ccode>ValidationPipe\u003C\u002Fcode>) just ignores the extra field and does its own thing. \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode> is the one pipe in \u003Ccode>@nestjs\u002Fcommon\u003C\u002Fcode> that looks at \u003Ccode>metadata.schema\u003C\u002Fcode> and acts on it — and, like every other pipe, it only runs if you put it in the pipe chain.\u003C\u002Fp>",{"id":170,"html":171,"type":96},"b18","\u003Cp>That single fact explains the whole feature:\u003C\u002Fp>",{"id":173,"type":110,"items":174,"ordered":13},"b19",[175,176,177],"\u003Cstrong>\u003Ccode>schema\u003C\u002Fcode> is a request for validation, not validation itself.\u003C\u002Fstrong> Attaching it costs nothing at runtime unless a pipe reads it.","\u003Cstrong>\u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode> does the actual work\u003C\u002Fstrong>, by calling the schema&#39;s own \u003Ccode>~standard.validate()\u003C\u002Fcode> method — the one method every \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fstandard-schema\u002Fstandard-schema\">Standard Schema\u003C\u002Fa>-compatible library (Zod, Valibot, ArkType, and others) implements. NestJS doesn&#39;t depend on any specific schema library to do this; \u003Ccode>@standard-schema\u002Fspec\u003C\u002Fcode> is only a TypeScript type import in \u003Ccode>@nestjs\u002Fcommon\u003C\u002Fcode>, not a runtime dependency.","\u003Cstrong>The pipe&#39;s job ends at \u003Ccode>transform()\u003C\u002Fcode>.\u003C\u002Fstrong> 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).",{"id":179,"html":180,"text":180,"type":104,"level":47},"b20","Stage 1: registering StandardSchemaValidationPipe",{"id":182,"html":183,"type":96},"b21","\u003Cp>The smallest version that actually validates something:\u003C\u002Fp>",{"id":185,"code":186,"type":150,"language":151,"highlight":187},"b22","\u002F\u002F main.ts\nimport { NestFactory } from \"@nestjs\u002Fcore\";\nimport { StandardSchemaValidationPipe } from \"@nestjs\u002Fcommon\";\nimport { AppModule } from \".\u002Fapp.module\";\n\nasync function bootstrap() {\n  const app = await NestFactory.create(AppModule);\n  app.useGlobalPipes(new StandardSchemaValidationPipe());\n  await app.listen(3000);\n}\nbootstrap();",[],{"id":189,"html":190,"type":96},"b23","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> this is the exact same registration shape as \u003Ccode>app.useGlobalPipes(new ValidationPipe())\u003C\u002Fcode> — because it&#39;s the exact same mechanism. \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode> is a normal \u003Ccode>PipeTransform\u003C\u002Fcode>; nothing about \u003Ccode>schema\u003C\u002Fcode> 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&#39;d scope any pipe with \u003Ccode>@UsePipes()\u003C\u002Fcode>, if only part of your API has moved to Standard Schema.\u003C\u002Fp>",{"id":192,"html":193,"type":96},"b24","\u003Cp>With the pipe registered, the intro&#39;s \u003Ccode>POST \u002Fusers\u003C\u002Fcode> with \u003Ccode>{}\u003C\u002Fcode> now behaves correctly: the handler never runs, and the caller gets a \u003Ccode>400\u003C\u002Fcode> (covered in \u003Ca href=\"#stage-4-reading-and-customizing-validation-errors\">Stage 4\u003C\u002Fa>).\u003C\u002Fp>",{"id":195,"html":196,"text":196,"type":104,"level":47},"b25","Stage 2: schema on @Body(), @Query(), and @Param()",{"id":198,"html":199,"type":96},"b26","\u003Cp>The \u003Ccode>schema\u003C\u002Fcode> option is available on every parameter decorator that pulls data out of the request: \u003Ccode>@Body()\u003C\u002Fcode>, \u003Ccode>@Query()\u003C\u002Fcode>, \u003Ccode>@Param()\u003C\u002Fcode>, and \u003Ccode>@RawBody()\u003C\u002Fcode>. Attaching it is consistent across all of them — but whether \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode> actually validates it by default isn&#39;t; see the \u003Ccode>@RawBody()\u003C\u002Fcode> gotcha below.\u003C\u002Fp>",{"id":201,"code":202,"type":150,"language":151,"highlight":203},"b27","import { Body, Controller, Get, Param, Post, Query } from \"@nestjs\u002Fcommon\";\nimport { z } from \"zod\";\n\nconst createUserSchema = z.object({\n  name: z.string().min(1),\n  age: z.coerce.number().int().positive(),\n});\n\nconst listUsersQuerySchema = z.object({\n  page: z.coerce.number().int().min(1).default(1),\n  role: z.enum([\"admin\", \"editor\", \"viewer\"]).optional(),\n});\n\nconst idParamSchema = z.string().uuid();\n\n@Controller(\"users\")\nexport class UsersController {\n  @Post()\n  create(@Body({ schema: createUserSchema }) body: z.infer\u003Ctypeof createUserSchema>) {\n    return { created: body };\n  }\n\n  @Get()\n  list(@Query({ schema: listUsersQuerySchema }) query: z.infer\u003Ctypeof listUsersQuerySchema>) {\n    return { page: query.page, role: query.role ?? \"all\" };\n  }\n\n  @Get(\":id\")\n  findOne(@Param(\"id\", { schema: idParamSchema }) id: string) {\n    return { id };\n  }\n}",[],{"id":205,"html":206,"type":96},"b28","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> \u003Ccode>@Param(&#39;id&#39;, { schema })\u003C\u002Fcode> validates a single named parameter, the same way \u003Ccode>@Param(&#39;id&#39;, ParseUUIDPipe)\u003C\u002Fcode> always has; \u003Ccode>@Param({ schema })\u003C\u002Fcode> with no property name validates the \u003Cem>entire\u003C\u002Fem> params object against a schema shaped like \u003Ccode>{ id: string }\u003C\u002Fcode>. \u003Ccode>@Query()\u003C\u002Fcode> follows the identical pattern. There&#39;s no new mental model per decorator — it&#39;s the same \u003Ccode>{ schema }\u003C\u002Fcode> option everywhere, because it&#39;s the same \u003Ccode>ArgumentMetadata.schema\u003C\u002Fcode> field everywhere.\u003C\u002Fp>",{"id":208,"html":209,"text":209,"type":104,"level":47},"b29","Stage 3: transform — coercion, not just checking",{"id":211,"html":212,"type":96},"b30","\u003Cp>By default, \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode> doesn&#39;t just check that a value is valid — it replaces the value with whatever the schema \u003Cem>produces\u003C\u002Fem>. That matters because HTTP bodies and query strings arrive as strings and raw JSON, and a schema like \u003Ccode>z.coerce.number()\u003C\u002Fcode> in the \u003Ccode>listUsersQuerySchema\u003C\u002Fcode> above turns the string \u003Ccode>&quot;2&quot;\u003C\u002Fcode> into the number \u003Ccode>2\u003C\u002Fcode> before your handler ever sees it.\u003C\u002Fp>",{"id":214,"code":215,"type":150,"language":151,"highlight":216},"b31","new StandardSchemaValidationPipe({\n  transform: true, \u002F\u002F default — handler receives the schema's parsed\u002Fcoerced output\n});\n\nnew StandardSchemaValidationPipe({\n  transform: false, \u002F\u002F handler receives the original, unmodified input\n});",[],{"id":218,"html":219,"type":96},"b32","\u003Cp>With the default \u003Ccode>transform: true\u003C\u002Fcode>, \u003Ccode>GET \u002Fusers?page=2\u003C\u002Fcode> gives your handler \u003Ccode>query.page === 2\u003C\u002Fcode>, a real number, not the string \u003Ccode>&quot;2&quot;\u003C\u002Fcode>. Set \u003Ccode>transform: false\u003C\u002Fcode> and the pipe only checks that the value is valid — it hands the handler back the exact object it received, untouched. That&#39;s the right choice when a downstream pipe or your own code needs the raw shape, or when you&#39;re validating something you don&#39;t want silently rewritten (a raw file buffer, for instance).\u003C\u002Fp>",{"id":221,"html":222,"type":96},"b33","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> this mirrors what \u003Ccode>ValidationPipe\u003C\u002Fcode>&#39;s own \u003Ccode>transform\u003C\u002Fcode> option has always done for \u003Ccode>class-validator\u003C\u002Fcode> DTOs — coercion is opt-out, not opt-in, and it&#39;s easy to forget that a \u003Ccode>&quot;42&quot;\u003C\u002Fcode> in the request became a \u003Ccode>42\u003C\u002Fcode> by the time your handler logs it.\u003C\u002Fp>",{"id":224,"html":225,"text":225,"type":104,"level":47},"b34","Stage 4: reading and customizing validation errors",{"id":227,"html":228,"type":96},"b35","\u003Cp>When \u003Ccode>schema[&#39;~standard&#39;].validate()\u003C\u002Fcode> returns issues instead of a value, \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode> formats each one into a single string — the issue&#39;s path, joined with \u003Ccode>.\u003C\u002Fcode>, prefixed to its message — and throws an exception built from the full list.\u003C\u002Fp>",{"id":230,"html":231,"type":96},"b36","\u003Cp>Sending \u003Ccode>POST \u002Fusers\u003C\u002Fcode> with \u003Ccode>{ &quot;name&quot;: &quot;&quot;, &quot;age&quot;: &quot;not-a-number&quot; }\u003C\u002Fcode> against the schema from Stage 1 produces something like:\u003C\u002Fp>",{"id":233,"code":234,"type":150,"language":235,"highlight":236},"b37","{\n  \"statusCode\": 400,\n  \"message\": [\n    \"name: String must contain at least 1 character(s)\",\n    \"age: Expected number, received nan\"\n  ],\n  \"error\": \"Bad Request\"\n}","json",[],{"id":238,"html":239,"type":96},"b38","\u003Cp>Both the HTTP status and the exception itself are configurable:\u003C\u002Fp>",{"id":241,"code":242,"type":150,"language":151,"highlight":243},"b39","new StandardSchemaValidationPipe({\n  errorHttpStatusCode: 422, \u002F\u002F default is 400 (Bad Request)\n  exceptionFactory: (issues) =>\n    new UnprocessableEntityException({\n      code: \"VALIDATION_FAILED\",\n      fields: issues.map((i) => ({ path: i.path, message: i.message })),\n    }),\n});",[],{"id":245,"html":246,"type":96},"b40","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> \u003Ccode>exceptionFactory\u003C\u002Fcode> 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 \u003Ccode>ValidationPipe\u003C\u002Fcode> has always offered; only the shape of the input (Standard Schema issues instead of \u003Ccode>class-validator\u003C\u002Fcode> errors) is different.\u003C\u002Fp>",{"id":248,"html":249,"text":249,"type":104,"level":47},"b41","Stage 5: the response side — StandardSchemaSerializerInterceptor",{"id":251,"html":252,"type":96},"b42","\u003Cp>Validation on the way in has a counterpart on the way out. \u003Ccode>StandardSchemaSerializerInterceptor\u003C\u002Fcode> validates (and optionally transforms) what a handler returns, the same role \u003Ccode>ClassSerializerInterceptor\u003C\u002Fcode> plays for \u003Ccode>class-transformer\u003C\u002Fcode>-decorated classes:\u003C\u002Fp>",{"id":254,"code":255,"type":150,"language":151,"highlight":256},"b43","import { Controller, Get, SerializeOptions, UseInterceptors } from \"@nestjs\u002Fcommon\";\nimport { StandardSchemaSerializerInterceptor } from \"@nestjs\u002Fcommon\";\nimport { z } from \"zod\";\n\nconst userResponseSchema = z.object({ id: z.string(), name: z.string() });\n\n@UseInterceptors(StandardSchemaSerializerInterceptor)\n@Controller(\"users\")\nexport class UsersController {\n  @Get(\":id\")\n  @SerializeOptions({ schema: userResponseSchema })\n  findOne() {\n    return { id: \"abc\", name: \"Ada\", passwordHash: \"…\" }; \u002F\u002F stripped down to the schema's shape\n  }\n}",[],{"id":258,"html":259,"type":96},"b44","\u003Cp>This article focuses on the request side, since that&#39;s where the &quot;it looks validated but isn&#39;t&quot; mistake bites — but it&#39;s worth knowing this exists so you&#39;re not reaching for \u003Ccode>ClassSerializerInterceptor\u003C\u002Fcode> out of habit on a route that already validates with a schema.\u003C\u002Fp>",{"id":261,"html":262,"text":262,"type":104,"level":47},"b45","Edge cases and gotchas",{"id":264,"type":110,"items":265,"ordered":12},"b46",[266,267,268,269,270,271],"\u003Cstrong>A \u003Ccode>schema\u003C\u002Fcode> with no registered pipe is silent, not an error.\u003C\u002Fstrong> This is the entire bug from the intro — no warning at boot, no lint rule, just metadata nobody reads. Always confirm \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode> is actually in the chain for that route.","\u003Cstrong>Custom parameter decorators are skipped by default.\u003C\u002Fstrong> A parameter built with \u003Ccode>createParamDecorator()\u003C\u002Fcode> has \u003Ccode>ArgumentMetadata.type === &#39;custom&#39;\u003C\u002Fcode>, and the pipe skips those unless you pass \u003Ccode>validateCustomDecorators: true\u003C\u002Fcode>.","\u003Cstrong>\u003Ccode>@RawBody({ schema })\u003C\u002Fcode> gets skipped by that same default, too.\u003C\u002Fstrong> Internally, \u003Ccode>@nestjs\u002Fcore\u003C\u002Fcode>&#39;s \u003Ccode>ParamsTokenFactory.exchangeEnumForString()\u003C\u002Fcode> only maps the body\u002Fquery\u002Fparam paramtypes to the strings \u003Ccode>&#39;body&#39;\u003C\u002Fcode>, \u003Ccode>&#39;query&#39;\u003C\u002Fcode>, and \u003Ccode>&#39;param&#39;\u003C\u002Fcode> — every other paramtype, \u003Ccode>RAW_BODY\u003C\u002Fcode> included, falls through to \u003Ccode>&#39;custom&#39;\u003C\u002Fcode>. So a \u003Ccode>@RawBody({ schema })\u003C\u002Fcode> parameter reaches \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode> with \u003Ccode>metadata.type === &#39;custom&#39;\u003C\u002Fcode> and is silently skipped unless the pipe is constructed with \u003Ccode>validateCustomDecorators: true\u003C\u002Fcode> — the exact &quot;attached but never read&quot; failure this article opens with, just on a decorator that looks like it should behave like \u003Ccode>@Body()\u003C\u002Fcode>.","\u003Cstrong>\u003Ccode>class-validator\u003C\u002Fcode> and \u003Ccode>class-transformer\u003C\u002Fcode> are now optional peer dependencies\u003C\u002Fstrong> — \u003Ccode>@nestjs\u002Fcommon\u003C\u002Fcode>&#39;s own \u003Ccode>package.json\u003C\u002Fcode> lists both under \u003Ccode>peerDependenciesMeta\u003C\u002Fcode> with \u003Ccode>optional: true\u003C\u002Fcode>. A project fully on Standard Schema doesn&#39;t need either installed.","\u003Cstrong>The two approaches coexist without conflict.\u003C\u002Fstrong> Both are ordinary pipes reading different parts of the same \u003Ccode>ArgumentMetadata\u003C\u002Fcode>; registering both globally is safe, since a class-based DTO parameter has no \u003Ccode>schema\u003C\u002Fcode> and a Standard Schema parameter has no class \u003Ccode>metatype\u003C\u002Fcode>.","\u003Cstrong>Values are sanitized before validation.\u003C\u002Fstrong> The pipe strips dangerous prototype-pollution keys (\u003Ccode>__proto__\u003C\u002Fcode> and friends) from the input before handing it to the schema.",{"id":273,"html":274,"text":274,"type":104,"level":47},"b47","Best practices",{"id":276,"type":110,"items":277,"ordered":12},"b48",[278,279,280,281,282],"\u003Cstrong>Register \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode> globally once\u003C\u002Fstrong>, the same way you&#39;d register \u003Ccode>ValidationPipe\u003C\u002Fcode> — don&#39;t scatter \u003Ccode>@UsePipes()\u003C\u002Fcode> per route unless it genuinely needs different options.","\u003Cstrong>Reach for a Standard Schema library when validation logic is shared outside NestJS\u003C\u002Fstrong> — a Zod schema also used on a frontend form, for instance — since the schema itself is portable in a way a \u003Ccode>class-validator\u003C\u002Fcode> DTO class isn&#39;t.","\u003Cstrong>Keep \u003Ccode>class-validator\u003C\u002Fcode> DTOs where decorators already carry the app&#39;s conventions\u003C\u002Fstrong> (Swagger \u003Ccode>@ApiProperty()\u003C\u002Fcode>, serialization groups). Migrate per-module, not per-app.","\u003Cstrong>Set \u003Ccode>transform: false\u003C\u002Fcode> explicitly when the raw input matters elsewhere in the pipe chain\u003C\u002Fstrong> — don&#39;t assume the default coercion is a no-op.","\u003Cstrong>Write a custom \u003Ccode>exceptionFactory\u003C\u002Fcode> once, at the global registration\u003C\u002Fstrong>, if your API has a house error shape.",{"id":284,"html":285,"text":285,"type":104,"level":47},"b49","FAQ",{"id":287,"html":288,"text":288,"type":104,"level":59},"b50","Does Standard Schema validation replace ValidationPipe and class-validator?",{"id":290,"html":291,"type":96},"b51","\u003Cp>No. Both ship in \u003Ccode>@nestjs\u002Fcommon\u003C\u002Fcode> 12.x. \u003Ccode>class-validator\u003C\u002Fcode> and \u003Ccode>class-transformer\u003C\u002Fcode> moved to optional peer dependencies, so a Standard-Schema-only project can drop them — but \u003Ccode>ValidationPipe\u003C\u002Fcode> itself hasn&#39;t been deprecated.\u003C\u002Fp>",{"id":293,"html":294,"text":294,"type":104,"level":59},"b52","Do I have to install Zod for this to work?",{"id":296,"html":297,"type":96},"b53","\u003Cp>No. \u003Ccode>@nestjs\u002Fcommon\u003C\u002Fcode> only imports \u003Ccode>@standard-schema\u002Fspec\u003C\u002Fcode> as a TypeScript type, not as a runtime dependency, so it has no opinion on which schema library you use. Any library implementing the \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fstandard-schema\u002Fstandard-schema\">Standard Schema\u003C\u002Fa> spec — Zod, Valibot, and ArkType are the best-known ones — works with \u003Ccode>{ schema }\u003C\u002Fcode> the same way.\u003C\u002Fp>",{"id":299,"html":300,"text":301,"type":104,"level":59},"b54","Why does \u003Ccode>@Body({ schema })\u003C\u002Fcode> compile and run but never validate anything?","Why does @Body({ schema }) compile and run but never validate anything?",{"id":303,"html":304,"type":96},"b55","\u003Cp>Almost always because \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode> isn&#39;t in the pipe chain for that route — see \u003Ca href=\"#stage-1-registering-standardschemavalidationpipe\">Stage 1\u003C\u002Fa>. \u003Ccode>schema\u003C\u002Fcode> is metadata the decorator attaches; nothing enforces it by itself.\u003C\u002Fp>",{"id":306,"html":307,"text":308,"type":104,"level":59},"b56","Can I use \u003Ccode>schema\u003C\u002Fcode> with a custom decorator built from \u003Ccode>createParamDecorator()\u003C\u002Fcode>?","Can I use schema with a custom decorator built from createParamDecorator()?",{"id":310,"html":311,"type":96},"b57","\u003Cp>Only if you construct the pipe with \u003Ccode>validateCustomDecorators: true\u003C\u002Fcode>. By default, \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode> treats any parameter of type \u003Ccode>&#39;custom&#39;\u003C\u002Fcode> as out of scope, since custom decorators can return arbitrary shapes the author may not intend to run through a schema at all.\u003C\u002Fp>",{"id":313,"html":314,"text":314,"type":104,"level":59},"b58","What does a nested validation error look like?",{"id":316,"html":317,"type":96},"b59","\u003Cp>Each issue&#39;s \u003Ccode>path\u003C\u002Fcode> array is joined with \u003Ccode>.\u003C\u002Fcode> and prefixed to its message — a failure on \u003Ccode>address.zip\u003C\u002Fcode> in a nested object becomes the string \u003Ccode>&quot;address.zip: Invalid input&quot;\u003C\u002Fcode> in the default error list. Pass your own \u003Ccode>exceptionFactory\u003C\u002Fcode> if you need the raw, unflattened \u003Ccode>path\u003C\u002Fcode> array instead of the joined string.\u003C\u002Fp>",{"id":319,"html":320,"text":320,"type":104,"level":47},"b60","Cheat sheet",{"id":322,"head":323,"rows":327,"type":360},"b61",[324,325,326],"Task","Code","Notes",[328,332,336,340,344,348,352,356],[329,330,331],"Attach a schema to a param","\u003Ccode>@Body({ schema: userSchema })\u003C\u002Fcode>","Also works on \u003Ccode>@Query()\u003C\u002Fcode>, \u003Ccode>@Param()\u003C\u002Fcode>, \u003Ccode>@RawBody()\u003C\u002Fcode> — but \u003Ccode>@RawBody()\u003C\u002Fcode> needs \u003Ccode>validateCustomDecorators: true\u003C\u002Fcode> to actually validate",[333,334,335],"Register the pipe globally","\u003Ccode>app.useGlobalPipes(new StandardSchemaValidationPipe())\u003C\u002Fcode>","Required — \u003Ccode>schema\u003C\u002Fcode> alone validates nothing",[337,338,339],"Validate a single named param","\u003Ccode>@Param(&#39;id&#39;, { schema: idSchema })\u003C\u002Fcode>","Validates just that property",[341,342,343],"Validate the whole params object","\u003Ccode>@Param({ schema: paramsSchema })\u003C\u002Fcode>","No property name argument",[345,346,347],"Keep the raw input (no coercion)","\u003Ccode>new StandardSchemaValidationPipe({ transform: false })\u003C\u002Fcode>","Default is \u003Ccode>transform: true\u003C\u002Fcode>",[349,350,351],"Custom error shape\u002Fstatus","\u003Ccode>new StandardSchemaValidationPipe({ exceptionFactory, errorHttpStatusCode })\u003C\u002Fcode>","\u003Ccode>exceptionFactory\u003C\u002Fcode> receives raw, unformatted issues",[353,354,355],"Validate a \u003Ccode>createParamDecorator()\u003C\u002Fcode> value","\u003Ccode>new StandardSchemaValidationPipe({ validateCustomDecorators: true })\u003C\u002Fcode>","Default is \u003Ccode>false\u003C\u002Fcode> — custom decorators are skipped",[357,358,359],"Validate the response instead of the request","\u003Ccode>@UseInterceptors(StandardSchemaSerializerInterceptor)\u003C\u002Fcode> + \u003Ccode>@SerializeOptions({ schema })\u003C\u002Fcode>","The output-side counterpart","table",{"id":362,"code":363,"type":150,"language":151,"highlight":364},"b62","\u002F\u002F main.ts\nconst app = await NestFactory.create(AppModule);\napp.useGlobalPipes(new StandardSchemaValidationPipe()); \u002F\u002F required — schema is inert without this\n\n\u002F\u002F users.controller.ts\nconst createUserSchema = z.object({\n  name: z.string().min(1),\n  age: z.coerce.number().int().positive(),\n});\n\n@Post()\ncreate(@Body({ schema: createUserSchema }) body: z.infer\u003Ctypeof createUserSchema>) {\n  return { created: body }; \u002F\u002F body.age is a number, coerced from the JSON string\n}",[],{"id":366,"html":367,"text":367,"type":104,"level":47},"b63","Key takeaways",{"id":369,"type":110,"items":370,"ordered":12},"b64",[371,372,373,374,375],"\u003Ccode>{ schema }\u003C\u002Fcode> on \u003Ccode>@Body()\u003C\u002Fcode>, \u003Ccode>@Query()\u003C\u002Fcode>, \u003Ccode>@Param()\u003C\u002Fcode>, and \u003Ccode>@RawBody()\u003C\u002Fcode> only attaches metadata — it does nothing until a pipe reads it, exactly like a class-based DTO does nothing without \u003Ccode>ValidationPipe\u003C\u002Fcode>.","\u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode>, new in \u003Ccode>@nestjs\u002Fcore\u003C\u002Fcode> 12.x, is that pipe, and it must be registered globally or per-route yourself.","\u003Ccode>transform\u003C\u002Fcode> defaults to \u003Ccode>true\u003C\u002Fcode>: the handler gets the schema&#39;s parsed\u002Fcoerced output, not necessarily the original request value.","Any \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fstandard-schema\u002Fstandard-schema\">Standard Schema\u003C\u002Fa>-compatible library works — NestJS depends on the spec, not on Zod specifically — and \u003Ccode>class-validator\u003C\u002Fcode>\u002F\u003Ccode>class-transformer\u003C\u002Fcode> are now optional, so the two validation styles can run side by side or replace each other module by module.","\u003Ccode>StandardSchemaSerializerInterceptor\u003C\u002Fcode> is the matching piece for outgoing responses, the same role \u003Ccode>ClassSerializerInterceptor\u003C\u002Fcode> plays today.",{"id":377,"html":378,"text":378,"type":104,"level":47},"b65","Ending",{"id":380,"html":381,"type":96},"b66","\u003Cp>The bug in the intro wasn&#39;t a broken schema or a NestJS defect — it was a decorator that reads like an instruction and is actually a label. \u003Ccode>{ schema: createUserSchema }\u003C\u002Fcode> tells Nest &quot;here is a schema, should anyone ask&quot; — it doesn&#39;t tell Nest to ask. That&#39;s the same shape of mistake \u003Ccode>new RolesGuard(...)\u003C\u002Fcode> outside the DI container makes, or a \u003Ccode>ValidationPipe\u003C\u002Fcode> nobody registered: a piece that looks complete on its own only works because something else, wired up separately, is watching for it. Once \u003Ccode>StandardSchemaValidationPipe\u003C\u002Fcode> is in the chain, the rest — coercion, custom error shapes, response-side serialization — is just configuration on a pipe you already understand.\u003C\u002Fp>",{"id":383,"html":384,"type":96},"b67","\u003Cp>Have you moved a route to Standard Schema validation yet, or are you holding the line with \u003Ccode>class-validator\u003C\u002Fcode> for now? Drop your reasoning in the comments.\u003C\u002Fp>",{"id":386,"html":387,"type":96},"b68","\u003C!-- playground:start -->",{"id":389,"html":390,"text":390,"type":104,"level":47},"b69","🎮 Try it yourself",{"id":392,"html":393,"type":96},"b70","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnestjs-weekly-standard-schema-validation\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":395,"html":396,"type":96},"b71","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":398,"html":399,"type":96},"b72","\u003C!-- playground:end -->",{"id":401,"html":402,"type":96},"b73","\u003C!-- quiz:start -->",{"id":404,"html":405,"text":405,"type":104,"level":47},"b74","🧠 Test yourself",{"id":407,"html":408,"type":96},"b75","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnestjs-weekly-standard-schema-validation\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":410,"html":411,"type":96},"b76","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":413,"html":414,"type":96},"b77","\u003C!-- quiz:end -->",{"id":416,"html":417,"type":96},"b78","\u003C!-- related:start -->",{"id":419,"html":420,"text":420,"type":104,"level":47},"b79","📚 Read next",{"id":422,"type":110,"items":423,"ordered":12},"b80",[424,425,426],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnestjs-weekly-testing-provider-overrides\">NestJS Testing Module: Provider Overrides (with Cheat Sheet)\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnestjs-weekly-guards-canactivate-reflector\">NestJS Guards: CanActivate, ExecutionContext &amp; Reflector\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnestjs-weekly-request-lifecycle\">NestJS Request Lifecycle Explained (with Cheat Sheet)\u003C\u002Fa>",{"id":428,"html":429,"type":96},"b81","\u003C!-- related:end -->",{"id":431,"type":432},"b82","divider",{"id":434,"html":435,"type":96},"b83","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":437,"html":438,"type":96},"b84","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":440,"type":110,"items":441,"ordered":12},"b85",[442,443,444],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","A NestJS 12 upgrade lands, and a `POST \u002Fusers` 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.\n\nThis is written against **`@nestjs\u002Fcore` 12.1.1** (verified 2026-09-29 via `npm view @nestjs\u002Fcore 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()`\u002F`@Query()`\u002F`@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\u002Fcommon` and `@nestjs\u002Fcore` source for `12.1.1`, not from a blog post about it.\n\n## What you'll learn\n\nBy the end of this article you'll be able to:\n\n- 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\n- Wire up `StandardSchemaValidationPipe` correctly, globally or per-route, next to your existing `ValidationPipe` usage\n- Predict when the pipe returns the schema's *transformed* value versus the original input, and control it with `transform`\n- Read the exact shape of a Standard Schema validation error and customize it with `exceptionFactory`\n- 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\n\n## Who this is for\n\nYou've written a NestJS controller and used `class-validator` DTOs with `ValidationPipe` at least once. If you haven't read the [NestJS request lifecycle](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fnestjs-request-lifecycle-explained-with-cheat-sheet-227l) 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](https:\u002F\u002Fzod.dev) or a similar schema library helps but isn't required; every example is explained inline.\n\n## Table of contents\n\n- [The problem: a schema that validates nothing](#the-problem-a-schema-that-validates-nothing)\n- [The mental model: schema is metadata, the pipe is the reader](#the-mental-model-schema-is-metadata-the-pipe-is-the-reader)\n- [Stage 1: registering StandardSchemaValidationPipe](#stage-1-registering-standardschemavalidationpipe)\n- [Stage 2: schema on @Body(), @Query(), and @Param()](#stage-2-schema-on-body-query-and-param)\n- [Stage 3: transform — coercion, not just checking](#stage-3-transform--coercion-not-just-checking)\n- [Stage 4: reading and customizing validation errors](#stage-4-reading-and-customizing-validation-errors)\n- [Stage 5: the response side — StandardSchemaSerializerInterceptor](#stage-5-the-response-side--standardschemaserializerinterceptor)\n- [Edge cases and gotchas](#edge-cases-and-gotchas)\n- [Best practices](#best-practices)\n- [FAQ](#faq)\n- [Cheat sheet](#cheat-sheet)\n- [Key takeaways](#key-takeaways)\n\n## The problem: a schema that validates nothing\n\nHere's the handler from the intro, in full:\n\n```typescript\nimport { Body, Controller, Post } from \"@nestjs\u002Fcommon\";\nimport { z } from \"zod\";\n\nconst createUserSchema = z.object({\n  name: z.string().min(1),\n  age: z.coerce.number().int().positive(),\n});\n\n@Controller(\"users\")\nexport class UsersController {\n  @Post()\n  create(@Body({ schema: createUserSchema }) body: z.infer\u003Ctypeof createUserSchema>) {\n    return { created: body };\n  }\n}\n```\n\nAnd here's `main.ts`, unchanged from before the upgrade:\n\n```typescript\n\u002F\u002F main.ts — looks fine, nothing here reads `schema`\nconst app = await NestFactory.create(AppModule);\napp.useGlobalPipes(); \u002F\u002F never called — no pipes registered at all\nawait app.listen(3000);\n```\n\nSend `POST \u002Fusers` 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.\n\n## The mental model: schema is metadata, the pipe is the reader\n\n**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\u002Fcommon` 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.\n\nThat single fact explains the whole feature:\n\n1. **`schema` is a request for validation, not validation itself.** Attaching it costs nothing at runtime unless a pipe reads it.\n2. **`StandardSchemaValidationPipe` does the actual work**, by calling the schema's own `~standard.validate()` method — the one method every [Standard Schema](https:\u002F\u002Fgithub.com\u002Fstandard-schema\u002Fstandard-schema)-compatible library (Zod, Valibot, ArkType, and others) implements. NestJS doesn't depend on any specific schema library to do this; `@standard-schema\u002Fspec` is only a TypeScript type import in `@nestjs\u002Fcommon`, not a runtime dependency.\n3. **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).\n\n## Stage 1: registering StandardSchemaValidationPipe\n\nThe smallest version that actually validates something:\n\n```typescript\n\u002F\u002F main.ts\nimport { NestFactory } from \"@nestjs\u002Fcore\";\nimport { StandardSchemaValidationPipe } from \"@nestjs\u002Fcommon\";\nimport { AppModule } from \".\u002Fapp.module\";\n\nasync function bootstrap() {\n  const app = await NestFactory.create(AppModule);\n  app.useGlobalPipes(new StandardSchemaValidationPipe());\n  await app.listen(3000);\n}\nbootstrap();\n```\n\n**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.\n\nWith the pipe registered, the intro's `POST \u002Fusers` with `{}` now behaves correctly: the handler never runs, and the caller gets a `400` (covered in [Stage 4](#stage-4-reading-and-customizing-validation-errors)).\n\n## Stage 2: schema on @Body(), @Query(), and @Param()\n\nThe `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.\n\n```typescript\nimport { Body, Controller, Get, Param, Post, Query } from \"@nestjs\u002Fcommon\";\nimport { z } from \"zod\";\n\nconst createUserSchema = z.object({\n  name: z.string().min(1),\n  age: z.coerce.number().int().positive(),\n});\n\nconst listUsersQuerySchema = z.object({\n  page: z.coerce.number().int().min(1).default(1),\n  role: z.enum([\"admin\", \"editor\", \"viewer\"]).optional(),\n});\n\nconst idParamSchema = z.string().uuid();\n\n@Controller(\"users\")\nexport class UsersController {\n  @Post()\n  create(@Body({ schema: createUserSchema }) body: z.infer\u003Ctypeof createUserSchema>) {\n    return { created: body };\n  }\n\n  @Get()\n  list(@Query({ schema: listUsersQuerySchema }) query: z.infer\u003Ctypeof listUsersQuerySchema>) {\n    return { page: query.page, role: query.role ?? \"all\" };\n  }\n\n  @Get(\":id\")\n  findOne(@Param(\"id\", { schema: idParamSchema }) id: string) {\n    return { id };\n  }\n}\n```\n\n**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.\n\n## Stage 3: transform — coercion, not just checking\n\nBy 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.\n\n```typescript\nnew StandardSchemaValidationPipe({\n  transform: true, \u002F\u002F default — handler receives the schema's parsed\u002Fcoerced output\n});\n\nnew StandardSchemaValidationPipe({\n  transform: false, \u002F\u002F handler receives the original, unmodified input\n});\n```\n\nWith the default `transform: true`, `GET \u002Fusers?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).\n\n**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.\n\n## Stage 4: reading and customizing validation errors\n\nWhen `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.\n\nSending `POST \u002Fusers` with `{ \"name\": \"\", \"age\": \"not-a-number\" }` against the schema from Stage 1 produces something like:\n\n```json\n{\n  \"statusCode\": 400,\n  \"message\": [\n    \"name: String must contain at least 1 character(s)\",\n    \"age: Expected number, received nan\"\n  ],\n  \"error\": \"Bad Request\"\n}\n```\n\nBoth the HTTP status and the exception itself are configurable:\n\n```typescript\nnew StandardSchemaValidationPipe({\n  errorHttpStatusCode: 422, \u002F\u002F default is 400 (Bad Request)\n  exceptionFactory: (issues) =>\n    new UnprocessableEntityException({\n      code: \"VALIDATION_FAILED\",\n      fields: issues.map((i) => ({ path: i.path, message: i.message })),\n    }),\n});\n```\n\n**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.\n\n## Stage 5: the response side — StandardSchemaSerializerInterceptor\n\nValidation 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:\n\n```typescript\nimport { Controller, Get, SerializeOptions, UseInterceptors } from \"@nestjs\u002Fcommon\";\nimport { StandardSchemaSerializerInterceptor } from \"@nestjs\u002Fcommon\";\nimport { z } from \"zod\";\n\nconst userResponseSchema = z.object({ id: z.string(), name: z.string() });\n\n@UseInterceptors(StandardSchemaSerializerInterceptor)\n@Controller(\"users\")\nexport class UsersController {\n  @Get(\":id\")\n  @SerializeOptions({ schema: userResponseSchema })\n  findOne() {\n    return { id: \"abc\", name: \"Ada\", passwordHash: \"…\" }; \u002F\u002F stripped down to the schema's shape\n  }\n}\n```\n\nThis 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.\n\n## Edge cases and gotchas\n\n- **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.\n- **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`.\n- **`@RawBody({ schema })` gets skipped by that same default, too.** Internally, `@nestjs\u002Fcore`'s `ParamsTokenFactory.exchangeEnumForString()` only maps the body\u002Fquery\u002Fparam 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()`.\n- **`class-validator` and `class-transformer` are now optional peer dependencies** — `@nestjs\u002Fcommon`'s own `package.json` lists both under `peerDependenciesMeta` with `optional: true`. A project fully on Standard Schema doesn't need either installed.\n- **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`.\n- **Values are sanitized before validation.** The pipe strips dangerous prototype-pollution keys (`__proto__` and friends) from the input before handing it to the schema.\n\n## Best practices\n\n- **Register `StandardSchemaValidationPipe` globally once**, the same way you'd register `ValidationPipe` — don't scatter `@UsePipes()` per route unless it genuinely needs different options.\n- **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.\n- **Keep `class-validator` DTOs where decorators already carry the app's conventions** (Swagger `@ApiProperty()`, serialization groups). Migrate per-module, not per-app.\n- **Set `transform: false` explicitly when the raw input matters elsewhere in the pipe chain** — don't assume the default coercion is a no-op.\n- **Write a custom `exceptionFactory` once, at the global registration**, if your API has a house error shape.\n\n## FAQ\n\n### Does Standard Schema validation replace ValidationPipe and class-validator?\n\nNo. Both ship in `@nestjs\u002Fcommon` 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.\n\n### Do I have to install Zod for this to work?\n\nNo. `@nestjs\u002Fcommon` only imports `@standard-schema\u002Fspec` 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](https:\u002F\u002Fgithub.com\u002Fstandard-schema\u002Fstandard-schema) spec — Zod, Valibot, and ArkType are the best-known ones — works with `{ schema }` the same way.\n\n### Why does `@Body({ schema })` compile and run but never validate anything?\n\nAlmost always because `StandardSchemaValidationPipe` isn't in the pipe chain for that route — see [Stage 1](#stage-1-registering-standardschemavalidationpipe). `schema` is metadata the decorator attaches; nothing enforces it by itself.\n\n### Can I use `schema` with a custom decorator built from `createParamDecorator()`?\n\nOnly 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.\n\n### What does a nested validation error look like?\n\nEach 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.\n\n## Cheat sheet\n\n| Task | Code | Notes |\n| --- | --- | --- |\n| Attach a schema to a param | `@Body({ schema: userSchema })` | Also works on `@Query()`, `@Param()`, `@RawBody()` — but `@RawBody()` needs `validateCustomDecorators: true` to actually validate |\n| Register the pipe globally | `app.useGlobalPipes(new StandardSchemaValidationPipe())` | Required — `schema` alone validates nothing |\n| Validate a single named param | `@Param('id', { schema: idSchema })` | Validates just that property |\n| Validate the whole params object | `@Param({ schema: paramsSchema })` | No property name argument |\n| Keep the raw input (no coercion) | `new StandardSchemaValidationPipe({ transform: false })` | Default is `transform: true` |\n| Custom error shape\u002Fstatus | `new StandardSchemaValidationPipe({ exceptionFactory, errorHttpStatusCode })` | `exceptionFactory` receives raw, unformatted issues |\n| Validate a `createParamDecorator()` value | `new StandardSchemaValidationPipe({ validateCustomDecorators: true })` | Default is `false` — custom decorators are skipped |\n| Validate the response instead of the request | `@UseInterceptors(StandardSchemaSerializerInterceptor)` + `@SerializeOptions({ schema })` | The output-side counterpart |\n\n```typescript\n\u002F\u002F main.ts\nconst app = await NestFactory.create(AppModule);\napp.useGlobalPipes(new StandardSchemaValidationPipe()); \u002F\u002F required — schema is inert without this\n\n\u002F\u002F users.controller.ts\nconst createUserSchema = z.object({\n  name: z.string().min(1),\n  age: z.coerce.number().int().positive(),\n});\n\n@Post()\ncreate(@Body({ schema: createUserSchema }) body: z.infer\u003Ctypeof createUserSchema>) {\n  return { created: body }; \u002F\u002F body.age is a number, coerced from the JSON string\n}\n```\n\n## Key takeaways\n\n- `{ 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`.\n- `StandardSchemaValidationPipe`, new in `@nestjs\u002Fcore` 12.x, is that pipe, and it must be registered globally or per-route yourself.\n- `transform` defaults to `true`: the handler gets the schema's parsed\u002Fcoerced output, not necessarily the original request value.\n- Any [Standard Schema](https:\u002F\u002Fgithub.com\u002Fstandard-schema\u002Fstandard-schema)-compatible library works — NestJS depends on the spec, not on Zod specifically — and `class-validator`\u002F`class-transformer` are now optional, so the two validation styles can run side by side or replace each other module by module.\n- `StandardSchemaSerializerInterceptor` is the matching piece for outgoing responses, the same role `ClassSerializerInterceptor` plays today.\n\n## Ending\n\nThe 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.\n\nHave 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.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnestjs-weekly-standard-schema-validation\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnestjs-weekly-standard-schema-validation\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [NestJS Testing Module: Provider Overrides (with Cheat Sheet)](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnestjs-weekly-testing-provider-overrides)\n- [NestJS Guards: CanActivate, ExecutionContext & Reflector](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnestjs-weekly-guards-canactivate-reflector)\n- [NestJS Request Lifecycle Explained (with Cheat Sheet)](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnestjs-weekly-request-lifecycle)\n\n\u003C!-- related:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":7,"canonical":447,"description":62},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnestjs-weekly-standard-schema-validation","01a0f0ef-ccc9-726d-a5e9-c18b18af7b4d",{"name":450,"part":451,"total":451,"items":452},"NestJS Deep Dive",6,[453,458,462,467,471,475],{"slug":454,"title":455,"publishedAt":456,"readingMinutes":457},"nestjs-weekly-request-lifecycle","NestJS Request Lifecycle Explained (with Cheat Sheet)","2026-08-28T17:01:41.941Z",13,{"slug":459,"title":460,"publishedAt":461,"readingMinutes":64},"nestjs-weekly-dependency-injection-providers-scopes","NestJS Dependency Injection Explained (with Cheat Sheet)","2026-09-13T08:40:18.434Z",{"slug":463,"title":464,"publishedAt":465,"readingMinutes":466},"nestjs-weekly-module-encapsulation-exports","NestJS Module Encapsulation Explained (with Cheat Sheet)","2026-09-13T08:40:50.070Z",15,{"slug":468,"title":469,"publishedAt":470,"readingMinutes":457},"nestjs-weekly-testing-provider-overrides","NestJS Testing Module: Provider Overrides (with Cheat Sheet)","2026-09-18T17:23:03.842Z",{"slug":472,"title":473,"publishedAt":474,"readingMinutes":64},"nestjs-weekly-guards-canactivate-reflector","NestJS Guards: CanActivate, ExecutionContext & Reflector","2026-09-26T12:25:39.001Z",{"slug":5,"title":7,"publishedAt":65,"readingMinutes":64},{"id":477,"locked":12},"01a0f0ef-cd62-70c5-8dfb-0e513ffeb26e",[479],{"id":4,"slug":5,"title":7,"_count":480},{"questions":10},[482],{"locale":32,"slug":5},{"id":4,"slug":5,"title":7,"_count":484,"questionCount":10},{"questions":10},[486,490,494,498,500,504,506,509,512,516,517,520],{"slug":487,"name":488,"articles":489},"webdev","Webdev",119,{"slug":491,"name":492,"articles":493},"javascript","Javascript",98,{"slug":495,"name":496,"articles":497},"frontend","Frontend",78,{"slug":85,"name":86,"articles":499},45,{"slug":501,"name":502,"articles":503},"css","Css",39,{"slug":82,"name":83,"articles":505},18,{"slug":507,"name":508,"articles":64},"performance","Performance",{"slug":510,"name":511,"articles":457},"react","React",{"slug":513,"name":514,"articles":515},"browser","Browser",11,{"slug":79,"name":80,"articles":515},{"slug":518,"name":519,"articles":10},"html","Html",{"slug":521,"name":522,"articles":523},"accessibility","Accessibility",7]