@asteasolutions/zod-to-openapi
@asteasolutions/zod-to-openapi turns Zod 4 schemas and a registry of route descriptions into OpenAPI 3.0, 3.1, or 3.2 JavaScript objects. It can add OpenAPI metadata through a Zod prototype extension or Zod's native meta method, reuse registered component references, describe parameters, bodies, responses, webhooks, and security schemes, and generate either components alone or a complete document. It removes duplicate schema writing, but it does not discover routes, validate requests automatically, serve Swagger UI, serialize YAML, or guarantee that runtime handlers match the document.
This is a strong fit for Zod 4 backends that accept explicit route registration and a build-time generation step. Skip it if you want framework discovery or still run Zod 3, and keep runtime validation plus OpenAPI linting as separate responsibilities.
Use it if
- Your API already validates inputs or outputs with Zod 4 and duplicated OpenAPI schemas keep drifting
- You want typed registration of paths, parameters, bodies, responses, webhooks, and reusable components
- You need to target OpenAPI 3.0, 3.1, or 3.2 from the same Zod source
- You can run a documentation generation script during build or CI and review the resulting artifact
- Your application is still on Zod 3: the README directs those users to version 7.3.4 and says that line will not receive active support
- You expect framework route discovery or runtime middleware: the API requires explicit registry.registerPath calls and separately calling Zod parse in your server
- Your schemas depend on types outside the documented supported list: generation throws UnknownZodTypeError unless you provide an OpenAPI type override
- You cannot tolerate prototype setup or import-order side effects: the openapi method requires extendZodWithOpenApi once, and the README warns tree-shaking users to preserve that entry module
- You expect Zod's unknown-key behavior to map automatically: the README notes ordinary Zod objects generate additionalProperties true unless you use strict or catchall
Setup reality
Install @asteasolutions/zod-to-openapi together with its required peer zod@^4. Version 9.1.0 supports CommonJS and ESM and includes declarations, but the simplest .openapi examples depend on a one-time global call to extendZodWithOpenApi(z). Put that in a common entry module before any schema module executes. If Webpack tree shaking is enabled, mark the extension module as a side effect or preload it as the README demonstrates; otherwise production can fail while development works. Zod 4's .meta({ id, description, example }) avoids the extension for straightforward schemas, but registered-schema inheritance and separate parameter metadata still require .openapi. A shared OpenAPIRegistry only contains definitions from modules that have actually run, so a build script must import the route modules before calling the generator. Pick OpenApiGeneratorV3, V31, or V32 deliberately because nullability, tuple representation, and newer document fields differ by specification version. generateDocument returns a JavaScript object, not YAML, so install a serializer such as yaml if that is the artifact you need. The package does not connect generated request schemas to Express, Fastify, or another runtime; call the same Zod schema in middleware or handlers. It also does not lint the finished specification or check that documented responses match live behavior, so run an OpenAPI validator and snapshot or diff the generated file in CI. Sort components alphabetically when stable diffs matter, and register raw OpenAPI components for security schemes or structures that are not Zod-shaped.
Patterns
Enable the openapi method onceextend-zod-once
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';
extendZodWithOpenApi(z);Run this common entry module before importing schemas that call .openapi, and preserve it as a side effect in tree-shaken builds.
Attach simple metadata without extending Zoduse-zod-meta
import { z } from 'zod';
const UserId = z.string().uuid().meta({
id: 'UserId',
description: 'Stable user identifier',
example: '2f1f9a71-0c0b-4eb5-b4a4-bd27b90fb061',
});Zod 4 meta covers ordinary schema IDs and metadata; parameter-specific metadata and registered-schema extension cases still need .openapi.
Register a reusable component schemaregister-component-schema
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';
const registry = new OpenAPIRegistry();
const User = registry.register('User', z.object({
id: z.string().uuid(),
name: z.string().min(1),
}));The returned value is still the Zod schema, so use the same User object for parsing and route documentation.
Document a GET route with a path parameterregister-get-route
registry.registerPath({
method: 'get',
path: '/users/{id}',
summary: 'Get a user',
request: { params: z.object({ id: z.string().uuid() }) },
responses: {
200: {
description: 'User found',
content: { 'application/json': { schema: User } },
},
404: { description: 'User not found' },
},
});Registration documents the route only; your web framework must still parse params with the same Zod schema at runtime.
Document a JSON request bodyregister-post-body
const CreateUser = z.object({ name: z.string().min(1) });
registry.registerPath({
method: 'post',
path: '/users',
request: {
body: {
required: true,
content: { 'application/json': { schema: CreateUser } },
},
},
responses: {
201: {
description: 'User created',
content: { 'application/json': { schema: User } },
},
},
});A request body wraps media types under content; required belongs to the body object, not the Zod schema.
Reuse a registered path parameterregister-shared-parameter
const UserId = registry.registerParameter(
'UserId',
z.string().uuid().openapi({
param: { name: 'id', in: 'path' },
description: 'User identifier',
}),
);
registry.registerPath({
method: 'delete',
path: '/users/{id}',
request: { params: z.object({ id: UserId }) },
responses: { 204: { description: 'Deleted' } },
});Parameter-level fields go inside param; fields outside param describe the parameter's schema.
Register and apply bearer authenticationadd-security-scheme
registry.registerComponent('securitySchemes', 'bearerAuth', {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
});
registry.registerPath({
method: 'get',
path: '/me',
security: [{ bearerAuth: [] }],
responses: { 200: { description: 'Current user' } },
});Security components are raw OpenAPI objects because authentication schemes are not Zod data schemas.
Generate a complete OpenAPI 3.0 documentgenerate-openapi-30
import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
const document = new OpenApiGeneratorV3(registry.definitions, {
sortComponents: 'alphabetically',
}).generateDocument({
openapi: '3.0.3',
info: { title: 'Users API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
});Use the V3 generator for OpenAPI 3.0.x; alphabetical components make generated-file diffs easier to review.
Generate OpenAPI 3.1 nullabilitygenerate-openapi-31
import { OpenApiGeneratorV31 } from '@asteasolutions/zod-to-openapi';
const MaybeName = registry.register('MaybeName', z.string().nullable());
const document = new OpenApiGeneratorV31(registry.definitions)
.generateDocument({
openapi: '3.1.0',
info: { title: 'Example API', version: '1.0.0' },
});OpenAPI 3.1 uses JSON Schema-style null types, unlike the nullable keyword generated for 3.0.
Describe OpenAPI 3.2 stream itemsdocument-event-stream
import { OpenApiGeneratorV32 } from '@asteasolutions/zod-to-openapi';
const Event = registry.register('Event', z.object({ message: z.string() }));
registry.registerPath({
method: 'get',
path: '/events',
responses: {
200: {
description: 'Event stream',
content: { 'text/event-stream': { itemSchema: Event } },
},
},
});
const document = new OpenApiGeneratorV32(registry.definitions).generateDocument({
openapi: '3.2.0',
info: { title: 'Events API', version: '1.0.0' },
});itemSchema is an OpenAPI 3.2 media-type field; do not emit it from the 3.0 or 3.1 generator.
Generate reusable components without routesgenerate-components-only
import { OpenApiGeneratorV31 } from '@asteasolutions/zod-to-openapi';
const { components } = new OpenApiGeneratorV31(registry.definitions)
.generateComponents();generateComponents returns a JavaScript object containing components only, useful when merging into an existing document.
Write the generated document during a buildwrite-document-build
import { writeFile } from 'node:fs/promises';
import './routes/users.js';
import { generateOpenAPI } from './openapi.js';
const document = generateOpenAPI();
await writeFile('./dist/openapi.json', JSON.stringify(document, null, 2) + '\n');Import every registration module before generation. The library returns an object; add a separate serializer if you need YAML.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| zod-openapi | npm | Choose it for another Zod-first OpenAPI generator with a different document-construction style and Zod 4 support |
| @anatine/zod-openapi | npm | Choose it when its schema conversion helpers match an existing Zod and NestJS-oriented stack |
| zod-to-json-schema | npm | Choose it when you only need JSON Schema conversion and will assemble OpenAPI paths and operations elsewhere |
| tsoa | npm | Choose it when TypeScript controllers and decorators should generate both routes and OpenAPI rather than starting from Zod |