Skip to main content
Node.js · lesson 19 of 20
Node.js advanced Lesson 19 of 20

NestJS Validation with Zod and Standard Schema

Reuse a Zod schema as a NestJS pipe via the Standard Schema spec, watch an unwired schema validate nothing, then reshape the error response safely.

By SkillByExample · Published

nodejs nestjs zod validation typescript

Validating Requests with Zod built CreateUserSchema for an Express middleware. NestJS: Modules, Controllers, and Providers covers how a module, controller, and provider fit together — this lesson reuses both without re-teaching either one: the same schema object, dropped into NestJS’s built-in StandardSchemaValidationPipe instead of a hand-written middleware. Every command runs against node:26.13 in Docker, NestJS 12.3.2 (@nestjs/common, @nestjs/core, @nestjs/platform-express), TypeScript 6.0.3, and Zod 4.6.7.

Reusing the Zod Schema

Zod 4 implements the Standard Schema spec — every schema it builds exposes a ~standard property that any compliant library’s own validation code can call, without a Zod-specific adapter. CreateUserSchema below is the same shape the Express lesson validated: trimmed name, a real email, an optional non-negative age.

// src/create-user.schema.ts
import { z } from "zod";

export const CreateUserSchema = z.object({
  name: z.string().trim().min(2),
  email: z.email(),
  age: z.number().int().min(0).optional(),
});

UsersService just stores whatever it’s given — the point of this lesson is what happens before a user ever reaches it:

// src/users.service.ts
import { Injectable } from "@nestjs/common";

export interface User {
  id: number;
  name: string;
  email: string;
  age?: number;
}

@Injectable()
export class UsersService {
  private readonly users: User[] = [];
  private nextId = 1;

  create(data: { name: string; email: string; age?: number }): User {
    const user: User = { id: this.nextId++, ...data };
    this.users.push(user);
    return user;
  }
}

A Schema That Validates Nothing

The NestJS docs’ own shape for a Standard Schema parameter is @Body({ schema: CreateUserSchema }) — an options object, not an instantiated pipe. Nothing stops this from compiling and running:

// src/users.controller.ts
import { Body, Controller, Post } from "@nestjs/common";
import { UsersService } from "./users.service.js";
import { CreateUserSchema } from "./create-user.schema.js";

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  create(@Body({ schema: CreateUserSchema }) body: any) {
    return this.usersService.create(body);
  }
}
// src/users.module.ts
import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller.js";
import { UsersService } from "./users.service.js";

@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}
// src/app.module.ts
import { Module } from "@nestjs/common";
import { UsersModule } from "./users.module.js";

@Module({
  imports: [UsersModule],
})
export class AppModule {}

main.ts builds the application the same way NestJS: Modules, Controllers, and Providers did, with no validation pipe registered anywhere yet:

// src/main.ts
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";

const app = await NestFactory.create(AppModule);
await app.listen(3000);
console.log("listening on :3000");
{
  "name": "nestjs-validation-with-zod",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "@nestjs/common": "12.3.2",
    "@nestjs/core": "12.3.2",
    "@nestjs/platform-express": "12.3.2",
    "reflect-metadata": "0.2.2",
    "rxjs": "7.8.2",
    "zod": "4.6.7"
  },
  "devDependencies": {
    "typescript": "6.0.3"
  }
}
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

node_modules lives in its own named Docker volume, nodejs-nestjs-validation-with-zod-modules, for the same reason every lesson in this topic uses one — a bind mount shares files with the Linux VM too slowly for node_modules’ thousands of small files:

$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund

added 97 packages in 19s

Building and starting the server shows nothing wrong — schema attaches to the parameter’s own metadata whether or not anything ever reads it, so compilation and startup have no way to notice a missing pipe:

$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-zod -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app node:26.13 node dist/main.js
80c8e26b1d3b4ce79c19609518abca703ee9f9edcc21cedaf5eb4098e3f91ef7
$ until docker logs nodejs-nest-zod 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ docker logs nodejs-nest-zod 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
[Nest] 1  - 11/04/2026, 3:41:38 AM     LOG [NestFactory] Starting Nest application...
[Nest] 1  - 11/04/2026, 3:41:38 AM     LOG [InstanceLoader] AppModule dependencies initialized +10ms
[Nest] 1  - 11/04/2026, 3:41:38 AM     LOG [InstanceLoader] UsersModule dependencies initialized +0ms
[Nest] 1  - 11/04/2026, 3:41:38 AM     LOG [RoutesResolver] UsersController {/users}: +6ms
[Nest] 1  - 11/04/2026, 3:41:38 AM     LOG [RouterExplorer] Mapped {/users, POST} route +3ms
[Nest] 1  - 11/04/2026, 3:41:38 AM     LOG [NestApplication] Nest application successfully started +2ms
listening on :3000

A request with a padded name and an invalid email shows exactly how much nothing happened:

$ curl -si http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"  B  ","email":"nope"}'
HTTP/1.1 201 Created
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 38
ETag: W/"26-GY1IgZF3CQB+Pl/Anm9BC37EIoI"
Date: Wed, 04 Nov 2026 03:41:42 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":1,"name":"  B  ","email":"nope"}

201 Created — Nest’s default success status for POST — for a name that’s one real character once trimmed and an email with no @ in it at all. Nothing ran CreateUserSchema against this body, so nothing trimmed name either: " B " comes back exactly as sent, padding and all. schema in @Body’s options object is data a pipe can read, not an instruction that one will run.

Wiring the Pipe

app.useGlobalPipes registers a StandardSchemaValidationPipe instance for every parameter that carries a schema:

// src/main.ts (registers the validation pipe globally)
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { StandardSchemaValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module.js";

const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new StandardSchemaValidationPipe());
await app.listen(3000);
console.log("listening on :3000");

UsersController doesn’t change at all — the pipe reads the exact same schema the controller already attached:

$ docker rm -f nodejs-nest-zod
nodejs-nest-zod
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-zod -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app node:26.13 node dist/main.js
0b3db016101a12f57d52e24ce52f3100761b5005e61158b40e8227c600b0cde7
$ until docker logs nodejs-nest-zod 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -si http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"  Bob  ","email":"bob@example.com"}'
HTTP/1.1 201 Created
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 47
ETag: W/"2f-qPZ/dNfTZWijOaU95vVSsNFYXZc"
Date: Wed, 04 Nov 2026 03:41:52 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":1,"name":"Bob","email":"bob@example.com"}
$ curl -s -w '\nSTATUS:%{http_code}\n' http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"B","email":"nope"}'
{"message":["name: Too small: expected string to have >=2 characters","email: Invalid email address"],"error":"Bad Request","statusCode":400}
STATUS:400

" Bob " comes back as "Bob" — the pipe’s transform option defaults to true, so the handler receives CreateUserSchema’s parsed output, trimmed, exactly the way validateBody in the Express lesson replaced req.body with result.data. The second request fails both checks, and message is an array of "path: reason" strings — one entry per failed field, built straight from Standard Schema’s own issue list, not from z.flattenError’s fieldErrors grouping the Express lesson used.

Reshaping the Error

StandardSchemaValidationPipe’s constructor takes an exceptionFactory option to produce a different response shape. Returning a plain object from it looks reasonable:

// src/main.ts (broken: exceptionFactory returns a plain object, not an HttpException)
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { StandardSchemaValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module.js";

const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
  new StandardSchemaValidationPipe({
    errorHttpStatusCode: 422,
    exceptionFactory: (issues) => ({
      error: "validation failed",
      details: Object.fromEntries(
        issues.map((issue) => [String(issue.path?.[0] ?? "_"), issue.message]),
      ),
    }),
  }),
);
await app.listen(3000);
console.log("listening on :3000");
$ docker rm -f nodejs-nest-zod
nodejs-nest-zod
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-zod -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app node:26.13 node dist/main.js
444b083010a1b9423233807b4c559e79af4defd27d404878611e1761bc418d20
$ until docker logs nodejs-nest-zod 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -si http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"B","email":"nope"}'
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 52
ETag: W/"34-rlKccw1E+/fV8niQk4oFitDfPro"
Date: Wed, 04 Nov 2026 03:42:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"statusCode":500,"message":"Internal server error"}

500, not 422 — the errorHttpStatusCode: 422 option never even takes effect, because exceptionFactory’s return value replaced the whole default factory, plain object and all. NestJS’s exception layer only recognizes a thrown HttpException (or a subclass); a bare object thrown directly is an unrecognized error, which the default global filter turns into the same generic 500 it would give any other uncaught bug. The fix is to throw an actual HttpException:

// src/main.ts (fixed: exceptionFactory returns an HttpException)
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { HttpException, StandardSchemaValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module.js";

const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
  new StandardSchemaValidationPipe({
    errorHttpStatusCode: 422,
    exceptionFactory: (issues) =>
      new HttpException(
        {
          error: "validation failed",
          details: Object.fromEntries(
            issues.map((issue) => [String(issue.path?.[0] ?? "_"), issue.message]),
          ),
        },
        422,
      ),
  }),
);
await app.listen(3000);
console.log("listening on :3000");
$ docker rm -f nodejs-nest-zod
nodejs-nest-zod
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-zod -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app node:26.13 node dist/main.js
1e115164f5370e08b27bf10023957d0823faeea9b31402a32812289ba467f643
$ until docker logs nodejs-nest-zod 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -si http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"B","email":"nope"}'
HTTP/1.1 422 Unprocessable Entity
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 132
ETag: W/"84-c1f5R/0p8urRmnbczW0gPKAgla4"
Date: Wed, 04 Nov 2026 03:42:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"validation failed","details":{"name":"Too small: expected string to have >=2 characters","email":"Invalid email address"}}
$ curl -si http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Zoe","email":"zoe@example.com"}'
HTTP/1.1 201 Created
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 47
ETag: W/"2f-ZQzMDTwrwtoesV/qVp6rd8PkSAI"
Date: Wed, 04 Nov 2026 03:42:14 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":1,"name":"Zoe","email":"zoe@example.com"}
$ docker rm -f nodejs-nest-zod
nodejs-nest-zod

422, with details grouped by field name — the same shape exceptionFactory tried to return before, now actually reaching the client because it’s wrapped in a real HttpException. errorHttpStatusCode: 422 is still sitting in the options object above, and it’s still inert: the constructor only reads it to build the default exceptionFactory (new HttpErrorByCode[errorHttpStatusCode](messages), from the pipe’s own source), and a custom exceptionFactory replaces that default outright. The 422 a client actually gets here comes entirely from new HttpException(body, 422) inside the custom factory — leaving the option in doesn’t hurt, but it isn’t doing anything until the custom factory is removed. A valid request still succeeds exactly as before: this change only touched what happens when validation fails.

Practice

1. Add a required role field to CreateUserSchema, restricted to "admin" or "member" with z.enum, and confirm the final pipe setup above (the HttpException-wrapped exceptionFactory) rejects an invalid role the same way it rejects a bad name or email.

Solution
// src/create-user.schema.ts (adds role)
import { z } from "zod";

export const CreateUserSchema = z.object({
  name: z.string().trim().min(2),
  email: z.email(),
  age: z.number().int().min(0).optional(),
  role: z.enum(["admin", "member"]),
});
// src/main.ts (unchanged from the main lesson's final version)
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { HttpException, StandardSchemaValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module.js";

const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
  new StandardSchemaValidationPipe({
    errorHttpStatusCode: 422,
    exceptionFactory: (issues) =>
      new HttpException(
        {
          error: "validation failed",
          details: Object.fromEntries(
            issues.map((issue) => [String(issue.path?.[0] ?? "_"), issue.message]),
          ),
        },
        422,
      ),
  }),
);
await app.listen(3000);
console.log("listening on :3000");
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-practice1 -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app node:26.13 node dist/main.js
ffae1688f4cacebedb7e48d6f4a22d3727aae968d0f36b5537caf060fe4f9ec5
$ until docker logs nodejs-nest-practice1 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -si http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Nora","email":"nora@example.com","role":"owner"}'
HTTP/1.1 422 Unprocessable Entity
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 103
ETag: W/"67-XZNsdxzSTkcfuQYJNxCdjri/24w"
Date: Wed, 04 Nov 2026 03:42:24 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"validation failed","details":{"role":"Invalid option: expected one of \"admin\"|\"member\""}}
$ docker rm -f nodejs-nest-practice1
nodejs-nest-practice1

"owner" isn’t one of the two allowed literal strings, so the pipe rejects it the same way it rejects an invalid name or email — role is just one more field in the same schema, and the global pipe doesn’t treat it any differently.

2. Remove the custom exceptionFactory, keeping only errorHttpStatusCode: 422, and confirm the default factory already honors a non-400 status code on its own — no custom factory needed just to change the status.

Solution
// src/main.ts (errorHttpStatusCode only, default exceptionFactory)
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { StandardSchemaValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module.js";

const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new StandardSchemaValidationPipe({ errorHttpStatusCode: 422 }));
await app.listen(3000);
console.log("listening on :3000");
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-practice2 -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app node:26.13 node dist/main.js
08ab6907987b28f89919c0a0452c0710bbdbaf57c48acdaa6ff976764c14f838
$ until docker logs nodejs-nest-practice2 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -si http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"B","email":"nope","role":"admin"}'
HTTP/1.1 422 Unprocessable Entity
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 150
ETag: W/"96-5CjN52TmzdLapQiIJGHrg0RHKc4"
Date: Wed, 04 Nov 2026 03:42:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"message":["name: Too small: expected string to have >=2 characters","email: Invalid email address"],"error":"Unprocessable Entity","statusCode":422}
$ docker rm -f nodejs-nest-practice2
nodejs-nest-practice2

422 Unprocessable Entity, with the same "path: reason" array shape the very first wiring example showed at 400 — errorHttpStatusCode alone is enough to change both the status code and the single top-level error label in the default factory’s output; a custom exceptionFactory is only needed to change the response shape, as the main lesson’s details grouping did.

3. Remove app.useGlobalPipes entirely and apply StandardSchemaValidationPipe directly on the one route that needs it, via pipes: [StandardSchemaValidationPipe] in @Body’s options object — a class reference, not an instance, so NestJS constructs it with no options (the default 400 factory).

Solution
// src/users.controller.ts (pipe applied per-route, not globally)
import { Body, Controller, Post, StandardSchemaValidationPipe } from "@nestjs/common";
import { UsersService } from "./users.service.js";
import { CreateUserSchema } from "./create-user.schema.js";

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  create(
    @Body({ schema: CreateUserSchema, pipes: [StandardSchemaValidationPipe] })
    body: any,
  ) {
    return this.usersService.create(body);
  }
}
// src/main.ts (no useGlobalPipes call)
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";

const app = await NestFactory.create(AppModule);
await app.listen(3000);
console.log("listening on :3000");
// src/create-user.schema.ts (back to the original 3-field schema, no role)
import { z } from "zod";

export const CreateUserSchema = z.object({
  name: z.string().trim().min(2),
  email: z.email(),
  age: z.number().int().min(0).optional(),
});
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-practice3 -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app node:26.13 node dist/main.js
1eb258bc2b41bc9d022a11ff34aa9259f8ab2cb97ba4455813535beff1948ec5
$ until docker logs nodejs-nest-practice3 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -si http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"  Bob  ","email":"bob@example.com"}'
HTTP/1.1 201 Created
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 47
ETag: W/"2f-qPZ/dNfTZWijOaU95vVSsNFYXZc"
Date: Wed, 04 Nov 2026 03:42:50 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":1,"name":"Bob","email":"bob@example.com"}
$ curl -s -w '\nSTATUS:%{http_code}\n' http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"B","email":"nope"}'
{"message":["name: Too small: expected string to have >=2 characters","email: Invalid email address"],"error":"Bad Request","statusCode":400}
STATUS:400
$ docker rm -f nodejs-nest-practice3
nodejs-nest-practice3

Same trimming, same validation, same default 400 shape as the very first wiring example — useGlobalPipes and a per-route pipes array both end up constructing the same pipe class; the difference is scope (every schema-carrying parameter in the app, versus just the ones that name the pipe explicitly) and, when passed an instance instead of a class, which options that one pipe gets constructed with.

4. Add an UpdateUserSchema with CreateUserSchema.partial(), wire it to a PATCH /users/:id route using the global pipe from the main lesson, and confirm an empty body {} passes.

Solution
// src/create-user.schema.ts (adds UpdateUserSchema)
import { z } from "zod";

export const CreateUserSchema = z.object({
  name: z.string().trim().min(2),
  email: z.email(),
  age: z.number().int().min(0).optional(),
});

export const UpdateUserSchema = CreateUserSchema.partial();
// src/users.controller.ts (adds PATCH /users/:id)
import { Body, Controller, Param, ParseIntPipe, Patch, Post } from "@nestjs/common";
import { UsersService } from "./users.service.js";
import { CreateUserSchema, UpdateUserSchema } from "./create-user.schema.js";

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  create(@Body({ schema: CreateUserSchema }) body: any) {
    return this.usersService.create(body);
  }

  @Patch(":id")
  update(
    @Param("id", ParseIntPipe) id: number,
    @Body({ schema: UpdateUserSchema }) body: any,
  ) {
    return { id, ...body };
  }
}
// src/main.ts (global pipe restored, from the main lesson's wiring section)
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { StandardSchemaValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module.js";

const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new StandardSchemaValidationPipe());
await app.listen(3000);
console.log("listening on :3000");
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-practice4 -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-validation-with-zod-modules:/app/node_modules -w /app node:26.13 node dist/main.js
6fe71a000d8937838d2d48f8fbbae5d4256f006c71d77e050c05b767c370cc92
$ until docker logs nodejs-nest-practice4 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -si -X PATCH http://localhost:3000/users/1 -H 'content-type: application/json' -d '{}'
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 8
ETag: W/"8-h5EdGu1QmHe4OkjsU292jNzSLfE"
Date: Wed, 04 Nov 2026 03:43:01 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":1}
$ docker rm -f nodejs-nest-practice4
nodejs-nest-practice4

{} passes because .partial() made every field in UpdateUserSchema optional, the same way it did for the Express lesson’s own UpdateUserSchema — PATCH returns 200 OK, Nest’s default for an update, not the 201 a POST gets.

Frequently Asked Questions

What is StandardSchemaValidationPipe and which NestJS version added it?
It's a built-in pipe in @nestjs/common that shipped with NestJS 12.0.0, released 2026-08-27 (this lesson pins the later 12.3.2 patch). It validates a route parameter against any schema implementing the Standard Schema spec — Zod 4+, Valibot, and ArkType all qualify. It reads the schema off the parameter's own metadata rather than being constructed with a schema argument, which is why wiring it up looks different from older NestJS pipes.
Why does @Body({ schema: CreateUserSchema }) do nothing by itself?
Passing schema in the options object only attaches that schema to the parameter's metadata — it doesn't validate anything on its own. Validation only happens when a StandardSchemaValidationPipe instance is actually in the pipe chain for that parameter, either registered globally with app.useGlobalPipes or passed in the same options object's pipes array. Without one, NestJS has metadata that nothing ever reads.
Why does a custom exceptionFactory that returns a plain object produce a 500 instead of my chosen status code?
NestJS's exception layer only gives special handling to a thrown HttpException (or one of its subclasses) — it reads the status code and response body off that object. A plain object thrown directly isn't recognized as an exception at all, so the default global exception filter replaces it with a generic 500 Internal Server Error, exactly the way it replaces any other unexpected thrown value. A custom exceptionFactory has to return new HttpException(body, statusCode), not a bare object.
How does this compare to NestJS's older ValidationPipe?
ValidationPipe validates a DTO class decorated with class-validator decorators (@IsString(), @IsEmail(), and so on) and needs class-transformer to turn a plain request body into an instance of that class first. StandardSchemaValidationPipe validates a schema object directly — no DTO class, no decorators, no class-transformer — so the exact same Zod schema that validates an Express request body works unchanged inside Nest.
Does the pipe mutate the original request body?
Almost never, with one narrow, documented exception. Before validating, transform() calls an internal stripProtoKeys() helper on the incoming value in place, deleting any __proto__, prototype, or constructor keys it finds — a prototype-pollution guard, not a correctness step, and not something a normal JSON body ever has. Past that one pass, transform() returns a new value — the schema's parsed output when transform is enabled (the default) — and NestJS passes that returned value into the route handler's parameter; the request object Express or Fastify built is otherwise untouched.