Skip to content

Repository files navigation

@cafercangundogdu/nestjs-zod-openapi

npm version npm downloads CI License: MIT NestJS Zod

Type-safe NestJS DTOs from Zod 4 schemas with full OpenAPI support — discriminatedUniononeOf, nested $ref, enum, and more.

Features

  • z.discriminatedUnion() → OpenAPI oneOf with discriminator
  • z.union() → OpenAPI anyOf
  • z.nativeEnum() → OpenAPI enum
  • .openapi('Name') → named $ref in components/schemas
  • ZodValidationPipe — request validation with structured error responses
  • createZodDto() — type-safe DTO classes from any Zod schema

Install

pnpm add @cafercangundogdu/nestjs-zod-openapi

Peer 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  // ✅ works

Union/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-openapi
  • SwaggerScanner.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/swagger internals, a major Swagger update may require a corresponding update here. Tested against @nestjs/swagger ^11.

Inspired by

License

MIT

About

NestJS 11 + Zod 4 + OpenAPI — type-safe DTOs with discriminatedUnion oneOf support

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages