@cafercangundogdu/nestjs-zod-openapi
v0.0.2
Published
NestJS + Zod 4 + OpenAPI — type-safe DTOs with discriminatedUnion oneOf support
Maintainers
Readme
@cafercangundogdu/nestjs-zod-openapi
Type-safe NestJS DTOs from Zod 4 schemas with full OpenAPI support —
discriminatedUnion→oneOf, nested$ref,enum, and more.
Features
z.discriminatedUnion()→ OpenAPIoneOfwithdiscriminatorz.union()→ OpenAPIanyOfz.nativeEnum()→ OpenAPIenum.openapi('Name')→ named$refincomponents/schemasZodValidationPipe— request validation with structured error responsescreateZodDto()— type-safe DTO classes from any Zod schema
Install
pnpm add @cafercangundogdu/nestjs-zod-openapiPeer dependencies: @nestjs/common ^11, @nestjs/swagger ^11, zod ^4
Quick start
1. Initialize Zod OpenAPI
Call initZodOpenApi(z) at the very top of your main.ts, before any module imports that use .openapi():
// src/zod-init.ts (create this file)
import { z } from 'zod';
import { initZodOpenApi } from '@cafercangundogdu/nestjs-zod-openapi';
initZodOpenApi(z);// src/main.ts
import './zod-init'; // must be first import
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { patchNestSwagger } from '@cafercangundogdu/nestjs-zod-openapi';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
patchNestSwagger({ schemasSort: 'alpha' });
const config = new DocumentBuilder()
.setTitle('My API')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
await app.listen(3000);
}
bootstrap();Why a separate file?
initZodOpenApi(z)must run before any DTO file that calls.openapi()is loaded. Putting it in a separate file imported first guarantees execution order.
2. Create DTOs
import { z } from 'zod';
import { createZodDto } from '@cafercangundogdu/nestjs-zod-openapi';
const UserSchema = z
.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
})
.openapi('User');
export class UserDto extends createZodDto(UserSchema) {}3. Validate requests
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { ZodValidationPipe } from '@cafercangundogdu/nestjs-zod-openapi';
@Module({
providers: [{ provide: APP_PIPE, useClass: ZodValidationPipe }],
})
export class AppModule {}Discriminated union → oneOf
const TextPart = z
.object({ type: z.literal('text'), content: z.string() })
.openapi('TextPart');
const ImagePart = z
.object({ type: z.literal('image'), url: z.string().url() })
.openapi('ImagePart');
const MessagePart = z
.discriminatedUnion('type', [TextPart, ImagePart])
.openapi('MessagePart');
// Union DTOs use const assignment (not extends)
export const MessagePartDto = createZodDto(MessagePart);
export type MessagePartInput = z.output<typeof MessagePart>;OpenAPI output:
MessagePart:
oneOf:
- $ref: '#/components/schemas/TextPart'
- $ref: '#/components/schemas/ImagePart'
discriminator:
propertyName: type
mapping:
text: '#/components/schemas/TextPart'
image: '#/components/schemas/ImagePart'Nested $ref
const Address = z
.object({ street: z.string(), city: z.string() })
.openapi('Address');
const User = z
.object({ name: z.string(), address: Address })
.openapi('User');User:
properties:
name: { type: string }
address: { $ref: '#/components/schemas/Address' }Union DTOs in controllers
Object DTOs work directly as types:
@Body() body: CreateUserDto // ✅ worksUnion/discriminatedUnion DTOs need a companion type:
// dto.ts
export const MessagePartDto = createZodDto(MessagePartSchema);
export type MessagePartInput = z.output<typeof MessagePartSchema>;
// controller.ts
@Post()
@ApiCreatedResponse({ type: MessagePartDto }) // Swagger
async create(@Body() body: MessagePartInput) { // TypeScript type
return body;
}API
| Export | Description |
|--------|-------------|
| initZodOpenApi(z) | Extends Zod with .openapi(). Call once before DTOs load. |
| createZodDto(schema) | Creates a DTO class from any Zod schema. |
| ZodValidationPipe | NestJS pipe — validates against DTO's Zod schema. |
| patchNestSwagger(opts?) | Patches Swagger to generate schemas from Zod. |
| isZodDto(value) | Type guard for ZodDto classes. |
Compatibility
| Dependency | Version |
|------------|---------|
| @nestjs/common | ^11 |
| @nestjs/swagger | ^11 |
| zod | ^4 |
| node | >= 22 |
How it works
patchNestSwagger() monkey-patches two @nestjs/swagger internals:
SchemaObjectFactory.prototype.exploreModelSchema— routes Zod DTOs through @asteasolutions/zod-to-openapiSwaggerScanner.prototype.scanApplication— merges generated schemas into the document
initZodOpenApi(z) passes your Zod instance to extendZodWithOpenApi(), avoiding duplicate-package issues in pnpm/monorepo setups.
Since this relies on
@nestjs/swaggerinternals, a major Swagger update may require a corresponding update here. Tested against@nestjs/swagger ^11.
Inspired by
- @asteasolutions/zod-to-openapi — Zod → OpenAPI schema generation
- @wahyubucil/nestjs-zod-openapi — Zod 3 + NestJS 10 integration
- @anatine/zod-nestjs — Zod DTO integration
- nestjs-zod — Zod integration for NestJS
