NestJS Zod ValidationPipe for body DTOs: working versions (tested 2026-10-15)
Validate NestJS request bodies with a reusable Zod ValidationPipe that returns 400 field errors, tested through @nestjs/testing and supertest without relying on emitDecoratorMetadata.
passed2 passed · 0 failed2026-10-15 16:02 UTC- Status
- passed
- Last passed
- 2026-10-15 16:02 UTC
- Tests
- 2 / 2
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38764699834
The code
Exactly the files that passed. Your agent can pull them with get_recipe("nestjs-zod-validation-pipe") (install), or see them on GitHub.
{
"name": "nestjs-zod-validation-pipe",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@nestjs/core": "12.1.3",
"@nestjs/common": "12.1.3",
"@nestjs/platform-express": "12.1.3",
"zod": "4.6.7",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.2"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15",
"@nestjs/testing": "12.1.3",
"supertest": "7.3.1",
"@types/supertest": "7.2.1",
"@types/express": "5.0.6"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true
}
}
import { BadRequestException, type PipeTransform } from "@nestjs/common";
import { z, type ZodType } from "zod";
export class ZodPipe<T> implements PipeTransform {
constructor(private readonly schema: ZodType<T>) {}
transform(value: unknown): T {
const r = this.schema.safeParse(value);
if (!r.success) {
throw new BadRequestException({ error: "invalid_body", issues: z.flattenError(r.error).fieldErrors });
}
return r.data;
}
}
import "reflect-metadata";
import { Body, Controller, Module, Post } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { z } from "zod";
import { ZodPipe } from "./zod-pipe.ts";
export const CreateUser = z.object({
email: z.email(),
name: z.string().min(2).max(80),
age: z.coerce.number().int().min(13).optional(),
});
@Controller("users")
class UsersController {
@Post()
create(@Body(new ZodPipe(CreateUser)) body: z.infer<typeof CreateUser>) {
return { id: 1, ...body };
}
}
@Module({ controllers: [UsersController] })
class AppModule {}
export async function createApp() {
const app = await NestFactory.create(AppModule, { logger: false });
await app.init();
return app;
}
import { test } from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { createApp } from "../src/app.ts";
test("accepts a valid body and coerces age", async () => {
const app = await createApp();
const res = await request(app.getHttpServer()).post("/users").send({ email: "[email protected]", name: "Ada", age: "30" });
assert.equal(res.status, 201);
assert.equal(res.body.age, 30);
await app.close();
});
test("rejects invalid body with field errors", async () => {
const app = await createApp();
const res = await request(app.getHttpServer()).post("/users").send({ email: "nope", name: "x" });
assert.equal(res.status, 400);
assert.ok(res.body.message?.issues?.email || res.body.issues?.email || res.body.message);
await app.close();
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Anthropic TypeScript SDK tool use loop, tested with a mocked fetch
Run a Claude tool_use loop with the official Anthropic SDK and Zod-validated tool inputs, tested offline with an injected fetch.
better-sqlite3 transactions with Zod-validated inputs
A small ledger on better-sqlite3 using db.transaction for atomic transfers, nested transactions as savepoints for all-or-nothing batches, and Zod 4 to validate inputs and rows, tested on in-memory SQLite.
Cheerio HTML scraping with Zod-validated records and saved fixtures
Scrape a product listing and a spec table with Cheerio, normalise prices and relative URLs, and validate every record with Zod 4 so broken cards are reported, tested offline against saved HTML fixtures.
Commander CLI with Zod option validation, tested in-process
A Commander subcommand CLI with variadic arguments, repeatable options, choices and Zod-backed argument parsers, made testable with exitOverride and configureOutput so node:test runs it in-process without spawning.
All @nestjs/core recipesAll @nestjs/common recipesAll Zod recipes