rodrigowbazevedo/zod-nest(opens in a new tab)
Language: TypeScript
Modern Zod v4-only NestJS + OpenAPI 3.1 integration. Successor to nestjs-zod.
- stars
- 4
- watchers
- 1
- forks
- 0
- open issues
- 2
- Last updated
- 10/27/2026
Language: TypeScript
Modern Zod v4-only NestJS + OpenAPI 3.1 integration. Successor to nestjs-zod.
Modern Zod v4 ↔ NestJS ↔ OpenAPI 3.1 / 3.2 integration.
Define your DTOs once with Zod, get validated request bodies, validated response bodies, and a correct OpenAPI 3.1 or 3.2 document — without the dual-codepath, post-process, or @ts-ignore baggage that comes with bolting Zod onto class-validator-shaped tooling.
zod-nest is a fresh take on the idea pioneered by nestjs-zod — many thanks to that project and its maintainers; this library would not exist without it.
The difference is that zod-nest is Zod v4 only and OpenAPI 3.1+ only (3.1 or 3.2; no 3.0). It drops class-validator / class-transformer coexistence, drops Zod v3 codepaths, drops cleanupOpenApiDoc as a separate post-process, and drops the 20-odd @ts-ignores that the dual-version approach required. The result is a smaller surface, fully type-safe end to end, with extension points where you actually need them — exception factories, response status resolution, custom emission overrides.
For the long-form motivation, see docs/why-this-exists.md.
createZodDto — class wrapper around a Zod schema with introspectable schema, id, io, and a sibling Output class when input/output diverge.ZodValidationPipe — auto-detects DTO from handler-arg metatype, accepts an explicit DTO or raw Zod schema, customizable exception factory.@ZodResponse — stackable per status code; each entry is a DTO or a raw Zod schema (single, array [Entry], or tuple [A, B, …], mixable). A raw schema is normalised to an output DTO internally, so a z.discriminatedUnion / z.union / z.intersection that can't be wrapped in createZodDto (TS2509) drops straight into a response. No internal @HttpCode — caller controls status.@ZodBody / @ZodQuery / @ZodHeaders / @ZodCookies — method-level decorators that wire OpenAPI docs for schemas whose z.infer<> is a union (intersection-of-union, discriminated unions, etc.) — the ones that can't be wrapped in createZodDto because TS refuses unions as class bases (TS2509). Schema validation stays via @Body(new ZodValidationPipe(schema)); the type at the handler arg stays z.infer<>.ZodSerializerInterceptor — response validation with a passthroughOnError escape hatch for untrusted upstream shapes.applyZodNest — one call after SwaggerModule.createDocument(...) replaces the entire cleanupOpenApiDoc ritual.ZodNestModule.forRoot — global pipe + interceptor + logging configuration in one place; optional (everything works standalone).extend + getLineage emit OpenAPI allOf for derived schemas (@experimental — see Composition).createRegistry() for explicit isolation, defaultRegistry for the common process-wide case.ZodNestDocumentError with codes AMBIGUOUS_RENAME and DANGLING_REF so registry mis-configurations fail in CI, not at runtime.ZodNestUnrepresentableError surfaces bigint/date/transform constructs that JSON Schema can't represent (opt-out via strict: false).Override callback for file uploads, opaque blobs, or anything else Zod doesn't model.A short list of behavioural differences you'll hit on day one. Full migration table is in MIGRATION.md.
@ZodResponse — stack the decorator per status code. In nestjs-zod, multi-status required mixing @ZodSerializerDto with hand-rolled @ApiResponse({ status: ... }) calls.@HttpCode — @ZodResponse does not call @HttpCode under the hood. Status resolution precedence: @ZodResponse({ status }) → @HttpCode(...) on the handler → method default (POST → 201, others → 200). The caller controls 201 vs 200 vs 204 via standard NestJS decorators. status accepts numeric codes plus the OpenAPI 3.1 range keys ('1XX'…'5XX') and 'default' (sugar for the resolved method default).<Id>Output is only emitted when the input and output JSON Schemas actually differ. nestjs-zod always emitted _Output.3.0 fallback. Pick with DocumentBuilder.setOpenAPIVersion('3.2.0'); 3.2 is what makes QUERY and WebDAV routes conformant, what lets a streamed response say itemSchema instead of overstating the whole body, what emits Response Object summary, and what collapses a named query DTO to in: querystring. $refs emit to the final location; cleanupOpenApiDoc is unnecessary.nestjs-zod has none.ZodValidationPipe and ZodSerializerInterceptor accept a factory. nestjs-zod only customized the input side.Symbol.for('zod-nest.dto') (cross-realm safe), not MyDto.isZodDto.z.pipe / z.transform. No { codec: true } flag.x-zod-nest-* extensions. nestjs-zod left ~10 x-nestjs_zod-* keys behind.class-validator / class-transformer coexistence — zod-nest is Zod-native. Mixing class-validator decorators on a createZodDto result is not supported.createZodDto DTOs with plain @ApiProperty classes in the same controller is not tested.npm i zod-nest zod @nestjs/swagger
// user.dto.ts import { z } from 'zod'; import { createZodDto } from 'zod-nest'; const userSchema = z .object({ id: z.string(), email: z.email().transform((v) => v.toLowerCase()), }) .meta({ id: 'User' }); export class UserDto extends createZodDto(userSchema) {}
// users.controller.ts import { Body, Controller, Get, Post } from '@nestjs/common'; import { ZodResponse } from 'zod-nest'; import { UserDto } from './user.dto'; @Controller('users') export class UsersController { @Get('single') @ZodResponse({ type: UserDto }) single(): UserDto { return { id: 'u1', email: 'A@B.COM' } as UserDto; // transform lowercases on the way out } @Post() @ZodResponse({ type: UserDto }) create(@Body() body: UserDto): UserDto { // body is already validated + parsed by ZodValidationPipe return body; } }
// app.module.ts import { Module } from '@nestjs/common'; import { ZodNestModule } from 'zod-nest'; import { UsersController } from './users.controller'; @Module({ imports: [ZodNestModule.forRoot({ validationLogs: true })], controllers: [UsersController], }) export class AppModule {}
// main.ts import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { applyZodNest } from 'zod-nest'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); const raw = SwaggerModule.createDocument( app, new DocumentBuilder().setTitle('Users').setVersion('1').build(), ); const doc = applyZodNest(raw); SwaggerModule.setup('docs', app, doc); await app.listen(3000); } bootstrap();
That's the whole loop. Validation, response serialization, and a correct OpenAPI 3.1 document — all driven from one Zod schema per DTO.
Every DTO is one Zod schema wrapped in a class. The class exists so NestJS' introspection (parameter metatype, @nestjs/swagger) can find it; the validation and the OpenAPI emission both come from the schema directly. You don't repeat the shape with decorators.
The class returned by createZodDto(schema) carries schema, id, io: 'input', and a lazy Output sibling. parse / safeParse are static methods on the class. The class is tagged with Symbol.for('zod-nest.dto') so ZodValidationPipe and ZodSerializerInterceptor can discriminate it from plain constructors. The id comes from schema.meta({ id }) when present (preferred) or from the second-argument options.
Exposure is reachability-scoped. A schema lands in components.schemas when an endpoint in the document references it — directly or transitively through a $ref (response inner deps included), or as a query/param/header/cookie DTO captured via its marker. A registered schema that no endpoint reaches is pruned; force it in with { expose: true }. Anonymous schemas without an id stay inlined where used. This keeps several Swagger documents that share one registry from each carrying the whole catalog.
See docs/dto.md for the full surface.
A schema whose z.infer<> is a TypeScript union — z.union, z.discriminatedUnion, or z.intersection(obj, union) — can't be used as a class base because TS rejects unions as constructor return types (TS2509: Base constructor return type ... is not an object type). For these, skip createZodDto and pair the raw schema with parameter-level decorators that handle OpenAPI emission directly:
const IntersectionWithUnion = z .intersection( z.union([z.object({ a: z.string() }), z.object({ b: z.string() })]), z.union([z.object({ c: z.string() }), z.object({ d: z.string() })]), ) .meta({ id: 'IntersectionWithUnion' }); type IntersectionWithUnionType = z.infer<typeof IntersectionWithUnion>; @Controller() export class Controller { @Post() @ZodBody(IntersectionWithUnion) async post( @Body(new ZodValidationPipe(IntersectionWithUnion)) body: IntersectionWithUnionType, ): Promise<IntersectionWithUnionType> { return body; } }
The decorator set: @ZodBody, @ZodQuery, @ZodHeaders, @ZodCookies. All are method-level. They register the schema in the registry (so it lands in components.schemas when named) and apply the matching OpenAPI parameter metadata — @ZodBody writes the request body's $ref/inline schema; @ZodQuery / @ZodHeaders / @ZodCookies expand a z.object into one OpenAPI parameter per property. Validation stays manual via @Body(new ZodValidationPipe(schema)) so the handler arg keeps its precise z.infer<> type.
See docs/recipes/intersection-with-union.md for the full pattern.
Named query objects (both @Query() dto and @ZodQuery) collapse to a single in: querystring parameter under OpenAPI 3.2 — the field 3.2 added for exactly this — and expand to one parameter per field under 3.1. Nothing changes on the wire either way. The deprecated queryParamStyle / @ZodQuery({ ref }) flags override the default; see docs/swagger-integration.md → Query parameter style.
If a schema's input and output JSON Schemas are byte-equal (the common case), the OpenAPI doc emits a single components.schemas[Id]. If they differ (e.g. a transform, a pipe, an .optional().default(x) field), the doc emits two: Id for input, IdOutput for output. Response refs are rewritten to IdOutput automatically.
You don't pick the behaviour with a flag — applyZodNest compares the emitted bodies and decides per DTO.
Stack @ZodResponse to declare multiple status codes on one handler:
@Get(':id') @ZodResponse({ type: UserDto }) // success variant — status inferred (200 for GET) @ZodResponse({ status: 404, type: ErrorDto }) @ZodResponse({ status: 500, type: FatalDto }) getUser(): void {}
Recommended style: omit status for the success variant and let it infer from the route, then set status explicitly only for the off-happy-path variants. Keeps the signal-to-noise high — the explicit numbers in the snippet above are the ones the reader actually needs to scan for.
At request time, ZodSerializerInterceptor looks at response.statusCode and picks the matching variant. If you don't pass status, the variant matches on the handler's default (computed once at request time, in this order: @HttpCode(n) → POST → 201, everything else → 200). @ZodResponse does not internally apply @HttpCode — you stay in charge of the actual HTTP status.
See docs/responses.md for the precedence chain and passthroughOnError.
Streamed bodies are written straight to the response buffer — they shouldn't be validated, and the OpenAPI media type isn't application/json. Pass contentType (or declare a stream-typed @Header('Content-Type', …)) and @ZodResponse documents the response under that media type and skips validation:
@Sse('events') @ZodResponse({ type: NotificationEventDto, contentType: 'text/event-stream' }) streamEvents(): Observable<MessageEvent> { /* … */ }
stream is inferred true for known stream types (text/event-stream, application/x-ndjson, application/octet-stream, application/pdf, image/*, audio/*, video/*); override with stream: false, or extend the set globally via ZodNestModule.forRoot({ streamContentTypes: [...] }). See docs/recipes/streaming-responses.md.
import { z } from 'zod'; import { createZodDto } from 'zod-nest'; const userSchema = z.object({ id: z.uuid(), name: z.string() }).meta({ id: 'User' }); class UserDto extends createZodDto(userSchema) {} UserDto.parse({ id: '00000000-0000-0000-0000-000000000000', name: 'Ada' }); // → { id: '...', name: 'Ada' }
The id is what appears as the components.schemas key in the OpenAPI document and what $refs point at. You can set it in two equivalent ways:
// Preferred — on the schema, via Zod's metadata const userSchema = z.object({/* ... */}).meta({ id: 'User' }); class UserDto extends createZodDto(userSchema) {} // Also valid — passed through createZodDto's options class UserDto extends createZodDto(z.object({/* ... */}), { id: 'User' }) {}
Both produce the same OpenAPI output. .meta({ id }) is preferred when the schema is hoisted into its own const, because the id stays with the schema — composition (extend(parent, ...)), shared input/output via .meta({ id }) on the same schema reference, and any non-DTO use of the schema all pick up the same id without an extra hop through createZodDto's options. Use the createZodDto(schema, { id }) form when you don't own the schema (e.g. it comes from a third-party module) or when defining a small DTO with an inline schema, where chaining .meta() on the inline expression hurts readability.
If you pass neither, the class name is used as a fallback. Under minification — where class names become single mangled characters — zod-nest falls back to an _AnonZodDto_N id and prints a one-time console warning. Set an explicit id either way for production builds.
import { APP_PIPE } from '@nestjs/core'; import { ZodValidationPipe } from 'zod-nest'; @Controller('things') class ThingsController { @Post() create(@Body() body: CreateThingDto) { return { received: body }; } } @Module({ controllers: [ThingsController], providers: [{ provide: APP_PIPE, useClass: ZodValidationPipe }], }) class AppModule {}
Or use ZodNestModule.forRoot() — see Module options below — to wire the pipe globally along with the response interceptor.
On failure the pipe throws ZodValidationException (HTTP 400, body { statusCode, message: 'Validation failed', errors: z.treeifyError(zodError), errorCode: 'VALIDATION_FAILED' }; errorCode on @nestjs/common >= 12 only, see error codes).
import { HttpException, HttpStatus } from '@nestjs/common'; import { ZodValidationPipe } from 'zod-nest'; class UnprocessableEntityException extends HttpException { constructor(issuesCount: number) { super({ message: 'invalid input', issuesCount }, HttpStatus.UNPROCESSABLE_ENTITY); } } const pipe = new ZodValidationPipe({ schema: CreateThingDto, createValidationException: (zodError) => new UnprocessableEntityException(zodError.issues.length), });
The factory receives (zodError, argMetadata) and returns anything throw-able. Wire it module-wide via ZodNestModule.forRoot({ createValidationException: ... }).
@Controller('users') class UsersController { @Get('single') @ZodResponse({ type: UserDto }) single(): UserDto { return { id: 'u1', email: 'A@B.COM' }; } }
The interceptor validates the return value against UserDto.schema and applies any transform / pipe Zod stages — in the example above, email is lowercased to a@b.com before the response leaves.
Stack @ZodResponse per status code:
import { Get, HttpStatus } from '@nestjs/common'; import { ZodResponse } from 'zod-nest'; class UserDto extends createZodDto(z.object({ id: z.string() }), { id: 'User' }) {} class ErrorDto extends createZodDto(z.object({ code: z.number() }), { id: 'Error' }) {} class FatalDto extends createZodDto(z.object({ trace: z.string() }), { id: 'Fatal' }) {} class UsersController { @Get(':id') @ZodResponse({ type: UserDto }) // 200 inferred @ZodResponse({ status: HttpStatus.NOT_FOUND, type: ErrorDto }) @ZodResponse({ status: HttpStatus.INTERNAL_SERVER_ERROR, type: FatalDto }) getUser(): void {} }
At runtime the interceptor matches response.statusCode against each variant's status. The OpenAPI doc emits three responses[200|404|500] entries with the right DTO ref under each. See docs/responses.md for the full mechanism.
For an upstream shape you don't fully trust, set passthroughOnError: true on the variant. Validation failures are logged (if logging is on) but the original value passes through untouched:
@Get('proxied') @ZodResponse({ type: ProxyDto, passthroughOnError: true }) proxied(): unknown { return { upstream: 'value', extra: ['raw', 'shape'] }; }
Use this sparingly — it bypasses the contract you declared. Logging at warn severity makes the deviation visible without breaking the request.
@Get('list') @ZodResponse({ type: [UserDto] }) list(): UserDto[] { /* ... */ } @Get('pair') @ZodResponse({ type: [UserDto, TagDto] }) pair(): unknown { /* ... */ }
[Dto] validates as z.array(Dto.schema) and surfaces as OpenAPI type: array. [A, B, …] validates as z.tuple([A.schema, B.schema, …]) and surfaces as prefixItems. Empty arrays and non-DTO elements throw TypeError at decoration time, so typos surface at module load — not the first request.
import { applyZodNest } from 'zod-nest'; const raw = SwaggerModule.createDocument(app, config); const doc = applyZodNest(raw); SwaggerModule.setup('docs', app, doc);
applyZodNest walks the doc, replaces every x-zod-nest-dto marker with the real Zod-derived JSON Schema, applies the I/O suffix truth table, strips the markers, and validates the final ref graph. Any dangling ref throws ZodNestDocumentError({ code: 'DANGLING_REF' }) — the spec fails at boot, not at request time.
See docs/swagger-integration.md for Override, custom registries, and strict-mode behaviour.
import { ZodNestModule } from 'zod-nest'; @Module({ imports: [ ZodNestModule.forRoot({ validationLogs: { input: true, output: true }, redactKeys: ['password', 'token', 'sessionId'], createSerializationException: (err, ctx) => new MyCustomFiveHundred(err, ctx), }), ], controllers: [UsersController], }) class AppModule {}
forRoot() is optional — ZodValidationPipe and ZodSerializerInterceptor work standalone with safe defaults. Use forRoot when you want consistent logging, a custom logger, custom exceptions, or shared redaction across the pipe and the interceptor.
| Option | Type | Default | What it does |
|---|---|---|---|
createValidationException |
(err, argMetadata) => unknown |
uses ZodValidationException |
Custom 400 exception for input failures |
createSerializationException |
(err, executionContext) => unknown |
uses ZodSerializationException |
Custom 500 exception for output failures (strict mode only) |
validationLogs |
boolean | { input?, output? } |
false |
Opt-in failure-only logging |
logger |
LoggerService |
NestJS Logger |
Replace the logger (pino, winston, …) |
redactKeys |
readonly string[] |
DEFAULT_REDACT_KEYS |
Keys redacted in logs (replaces default list, no merge) |
maxLoggedValueBytes |
number |
4096 |
Truncate oversize logged values |
parseMode |
'auto' | 'sync-first' | 'async' |
'auto' |
Parse sync-first so zod/compile's fast path runs |
DEFAULT_REDACT_KEYS includes password, secret, apiKey, authorization, bearer, token, accessToken, refreshToken, jwt, cookie, set-cookie. Matching is case-insensitive and applied at any depth in the logged value.
Full reference (with every interaction note) lives in docs/module-options.md. Using zod/compile? See docs/recipes/zod-compile.md.
Validation logging fires only on failure, with side 'input' or 'output'. The default behaviour is off; opt in via validationLogs: true (both sides) or validationLogs: { input: true } / { output: true } (granular).
A log entry carries:
side — 'input' or 'output'severity — 'error' for strict failures, 'warn' for passthroughOnError failuresdto — the DTO class namevalue — the offending value, redacted and truncatedz.treeifyError(zodError)Redaction is case-insensitive at any depth — a key named Password deep in a nested object is replaced with '[REDACTED]' just like a top-level password. Truncation replaces values larger than maxLoggedValueBytes (UTF-8 bytes) with { _truncated: true, _originalBytes, _preview } so you keep enough context to debug without flooding the logger.
Supplying redactKeys replaces the default list — there is no merge. If you want to add keys, spread DEFAULT_REDACT_KEYS:
import { DEFAULT_REDACT_KEYS, ZodNestModule } from 'zod-nest'; ZodNestModule.forRoot({ validationLogs: true, redactKeys: [...DEFAULT_REDACT_KEYS, 'sessionId'], });
See docs/logging.md for custom-logger adapters (pino, winston), structured-logging shape, and performance characteristics.
@experimental— output shape may change as edge cases surface. Pin a minor version if you build on this surface.
zod-nest ships an extend helper that records a parent → child link and emits OpenAPI allOf for derived schemas:
import { extend, getLineage } from 'zod-nest'; const Base = z.object({ id: z.string() }).meta({ id: 'Base' }); const Child = extend(Base, (s) => s.extend({ role: z.string() }).meta({ id: 'Child' })); getLineage(Child); // → { op: 'extend', parent: Base }
The emitted Child schema is allOf: [{ $ref: '#/components/schemas/Base' }, { type: 'object', properties: { role: { type: 'string' } }, required: ['role'] }]. The parent must be registered (via .meta({ id }) or createZodDto) for the $ref to resolve; anonymous parents fall back to flat emission.
See docs/composition.md for the full contract, current limitations, and the roadmap for non-extend operators.
A compact, link-out index. Type signatures and detailed semantics live in the companion docs.
DTO — docs/dto.md
createZodDto(schema, options?), isZodDto(value), isZodSchema(value), ZodDto<TSchema>, IoValidation — docs/validation-pipe.md
ZodValidationPipe, ZodValidationException, ZodValidationPipeOptions, CreateValidationException, ZodNestErrorCode (error codes, @nestjs/common >= 12)Response — docs/responses.md
@ZodResponse({ status?, type, description?, passthroughOnError?, contentType?, stream? }) (type is a DTO or raw schema, or an array/tuple of either), ZodSerializerInterceptor, ZodSerializationException, defaultStatusFor, resolveEffectiveStatus, ResponseStatusInput, ResponseStatusWildcard, ResponseVariant, ZodResponseEntry, ZodResponseType, ZOD_RESPONSES_METADATA_KEYParameter decorators for raw schemas — docs/recipes/intersection-with-union.md
@ZodBody(schema, options?), @ZodQuery(schema, options?), @ZodHeaders(schema, options?), @ZodCookies(schema, options?), ZodBodyOptions, ZodQueryOptions, ZodHeadersOptions, ZodCookiesOptionsDocument — docs/swagger-integration.md
applyZodNest(rawDoc, options), ApplyZodNestOptions, ZodNestDocumentErrorModule — docs/module-options.md / docs/logging.md
ZodNestModule.forRoot(options?), ZodNestModuleOptions, DEFAULT_REDACT_KEYS, DEFAULT_MAX_LOGGED_VALUE_BYTES, DEFAULT_STREAM_CONTENT_TYPES, SEQUENTIAL_MEDIA_TYPES, ZOD_NEST_OPTIONS, ParseModeSchema engine — single-schema mode and extension points
toOpenApi(schema, opts), createRegistry(), defaultRegistry, registerSchema(schema, registry?, options?), ZodNestRegistry, RegisterSchemaOptions, Override, OverrideContext, overrideJSONSchema(schema, fragment | { input?, output? }), OverrideJSONSchemaArg, ZodNestError, ZodNestUnrepresentableError, extend, getLineage, LineageEntryHelpers (subpath: zod-nest/helpers) — common JSON Schema fragments + presets for assembling overrides
dateTimeFragment, dateFragment, timeFragment, uuidFragment, emailFragment, uriFragment, hostnameFragment, ipv4Fragment, ipv6Fragment, binaryFragment, byteFragment, int32Fragment, int64Fragment, floatFragment, doubleFragment, opaqueFragmentbinary(opts?), opaque(opts?)enrich(base, extras) — extras are typed per fragment familyFileSchema, BlobSchema, BufferSchema (all z.instanceof(...) + binaryFragment)See docs/recipes/custom-openapi-overrides.md for the full catalog and usage patterns.
File uploads (subpaths: zod-nest/express, zod-nest/fastify) — docs/file-uploads.md
Dedicated helpers per multipart parser, because multer and @fastify/multipart disagree on where files land, what they're named, and whether a size is even reported.
multerMemoryFile(options?), multerDiskFile(options?), MulterMemoryFileSchema, MulterDiskFileSchema, MulterMemoryFileLike, MulterDiskFileLike, MulterFileOptions@fastify/multipart: fastifyMultipartFile(options?), FastifyMultipartFileSchema, FastifyMultipartFileLike, FastifyMultipartFileOptions (no maxSize — a MultipartFile reports no size)@ZodMultipart(shape, options?) declares the whole body and is the source of truth for @ZodUploadedFile(name), @ZodUploadedFiles(name?), and @ZodMultipartBody()@Post('avatar') @UseInterceptors(FileInterceptor('avatar')) @ZodMultipart({ avatar: multerMemoryFile({ maxSize: 2 * 1024 * 1024, mimeTypes: ['image/png'] }), name: z.string(), }) upload( @ZodUploadedFile('avatar') avatar: MulterMemoryFileLike, @ZodMultipartBody() body: { name: string }, ) {}
| Topic | Doc |
|---|---|
| Why this library exists | docs/why-this-exists.md |
createZodDto in depth |
docs/dto.md |
| Input validation | docs/validation-pipe.md |
| Responses, multi-status, status resolution | docs/responses.md |
| Module options reference | docs/module-options.md |
| Validation logging | docs/logging.md |
| Swagger integration & custom emission | docs/swagger-integration.md |
| File uploads (multipart/form-data) | docs/file-uploads.md |
| Composition (experimental) | docs/composition.md |
| Schema identity & clones | docs/schema-identity.md |
| Exception classes | docs/exceptions.md |
| Recipes | docs/recipes/ |
zod-nest declares explicit min + max peer-dep ranges in package.json: zod >=4.4.0 <5.0.0, @nestjs/common >=11.0.1 <13.0.0, @nestjs/core >=11.0.1 <13.0.0, @nestjs/swagger >=11.0.0 <13.0.0, rxjs >=7.6.0 <8.0.0, reflect-metadata >=0.2.0 <0.3.0, Node >=22.12. CI validates those claims by running the full test suite against the cells below; a red cell is a real blocker. Upper bounds are deliberate — a new peer major has to land in a real PR with a /check-upstream-updates audit before consumers can install it against this library.
NestJS 11 and 12 are both supported. The Node floor is >=22.12 rather than >=22 because NestJS 12 ships as ESM: a CommonJS app doing require('zod-nest') reaches require('@nestjs/common'), and unflagged require(esm) only landed in Node 22.12.0.
| Cell | zod |
@nestjs/common |
@nestjs/core |
@nestjs/swagger |
rxjs |
reflect-metadata |
|---|---|---|---|---|---|---|
floor |
4.4.0 | 11.0.1 | 11.0.1 | 11.0.0 | 7.6.0 | 0.2.0 |
zod-latest |
latest | 11.0.1 | 11.0.1 | 11.0.0 | 7.6.0 | 0.2.0 |
zod-4.5 |
4.5.4 | 11.0.1 | 11.0.1 | 11.0.0 | 7.6.0 | 0.2.0 |
nest-latest |
4.4.0 | latest | latest | latest | 7.6.0 | 0.2.0 |
nest12 |
4.4.0 | 12.0.1 | 12.0.1 | 12.0.1 | 7.6.0 | 0.2.0 |
rxjs-latest |
4.4.0 | 11.0.1 | 11.0.1 | 11.0.0 | latest | 0.2.0 |
reflect-metadata-latest |
4.4.0 | 11.0.1 | 11.0.1 | 11.0.0 | 7.6.0 | latest |
all-latest |
latest | latest | latest | latest | latest | latest |
One emission difference is visible across the supported zod range: from 4.5, a union of primitives — including .nullable() — emits { "type": ["string", "null"] } where 4.4 emitted { "anyOf": [{ "type": "string" }, { "type": "null" }] }. Both are valid OpenAPI 3.1 and validate identically; only the spelling changes. Everything else — $ref layout, composition allOf, input/output siblings, paths — is byte-identical across 4.4 and 4.5.
Cell definitions live in .github/compat-matrix.json. The CI workflow (.github/workflows/compat-matrix.yml) runs on every push to main and weekly on Monday — when a cell fails, the workflow opens (or comments on) a GitHub issue labelled compat-matrix-failure so the regression is tracked outside the Actions UI. Editing the JSON is the formal way to extend or shrink supported ranges. Node is not matrixed — the >=22.12 floor is enforced by engines. Cells that move NestJS also pin @nestjs/platform-express and @nestjs/testing to the same major (omitted from the table above for width) — both peer-require matching @nestjs/common + @nestjs/core majors, so moving the others without them just produces a peer conflict.
If you're coming from nestjs-zod, the headline changes are:
cleanupOpenApiDoc(SwaggerModule.createDocument(app, config)) with applyZodNest(SwaggerModule.createDocument(app, config)).@ApiOkResponse({ type: Dto }) + @ZodSerializerDto(Dto) pairs with @ZodResponse({ type: Dto }).class-validator / class-transformer if they were installed only for nestjs-zod interop.