Skip to content

Latest commit

 

History

History
264 lines (179 loc) · 8.07 KB

File metadata and controls

264 lines (179 loc) · 8.07 KB

zod-openapi v5 📚

This guide explains the key changes between zod-openapi v4 and v5.

You can migrate most of the way with our codemod:

pnpmx codemod-zod-openapi-v5 'src/**/*.ts'

npx codemod-zod-openapi-v5 'src/**/*.ts'

You can see the code here: codemod-zod-openapi-v5.

🔄 Differences from v4

🛠️ Runtime Changes

Using native Zod metadata

Zod v4 introduces a native .meta() method for metadata, eliminating the need for runtime extensions:

  • No more runtime monkey-patching
  • Removed: extendZodWithOpenApi(z) and import zod-openapi/extend
# Replace all schema definitions:
- z.string().openapi({ ... })
+ z.string().meta({ ... })

💡 Build-time optimization: You can now declare zod-openapi as a dev dependency if you create your schemas at build time.

⚠️ New requirements

  1. Zod v4 (3.25.74) required

    - import { z } from 'zod';
    + import * as z from 'zod/v4';
  2. Node v20+ required

    • Support for Node v18 has been dropped

📊 Schema Generation Changes

The internal schema generation has been completely replaced with Zod's native toJSONSchema() method. This should result in an overall performance improvement.

However, this will result in some changes to how some schemas are generated. Please raise a GitHub issue if you find any significant discrepancies.

🆕 OpenAPI version requirements

  • OpenAPI 3.1.0 is now the minimum required version
  • Support for OpenAPI 3.1.1 has been added

Lazy schemas supported natively

Zod automatically creates lazy components, so you can use z.lazy() without any additional configuration. It is still recommended to provide an id for lazy schemas.

🔄 Component references renamed: refid

- .openapi({ ref: 'MySchema' })
+ .meta({ id: 'MySchema' })

Wherever you previously used ref to create components within parameters, request objects and responses, has been replaced with id for consistency with Zod's syntax.

📝 Input & Output Schema Generation

Zod's native toJSONSchema() handles object schemas differently than our previous implementation:

  • 📥 Input schemas: Allow additional properties by default (no additionalProperties specified)
  • 📤 Output schemas: Strip extra properties (additionalProperties: false)

This better aligns with how Zod objects actually works, where input schemas are more permissive and output schemas are stricter and allows for structured outputs for AI applications.

As a result, when a schema containing a zod object with an ID is used in both request and response contexts, the library automatically generates two separate component schemas to properly represent the different behaviors.

How it works:

  1. Register a schema with an ID:
const schema = z
  .object({
    name: z.string(),
    age: z.number(),
  })
  .meta({
    id: 'Person',
  });
  1. When used in both contexts, it automatically generates:
components: {
  schemas: {
    Person: {
      type: 'object',
      properties: {
        name: { type: 'string' },
        age: { type: 'number' },
      },
    },
    PersonOutput: {
      type: 'object',
      properties: {
        name: { type: 'string' },
        age: { type: 'number' },
      },
      additionalProperties: false,
    },
  },
}

Customization options:

  • Custom output name: .meta({ id: 'Person', outputId: 'CustomOutputName' })
  • Global suffix: Set outputIdSuffix in CreateDocumentOptions to change from default "Output"

In certain cases, the same component can be used for both input and output contexts. This happens when the schemas are identical - for example, with primitive types like z.string().meta({ id: 'SimpleString'}). The library performs an equality check between input and output representations to determine when a single schema can be safely reused for both contexts.

For object schemas, you can explicitly control this behavior by using z.looseObject() (which allows additional properties) or z.strictObject() (which forbids them) instead of the standard z.object().

⚠️ Limitation: If your schema contains dynamically created lazy components, they won't be reused between input and output schemas.

Minor change: Manual schema generation

Previously, unsupported Zod schemas without a type field would throw an error. Now, any schema with fields will be accepted.

You can modify this behavior with the new allowEmptySchema option (see New Options below).

Minor change: .extend() behavior

  • No longer creates an embedded allOf referenced schema
  • This functionality would need to be contributed to the main Zod library if desired

🔧 Zod OpenAPI Options

🔁 Renamed Options

🔄 refTypeunusedIO

❌ Removed Options

⚙️ effectType

Transforms are not introspectable. effectType was introduced to attempt to address this and to try and keep the transform locked to the same type as the input schema. For transform operations, use Zod's native .overwrite() method, wrap your schema in a .pipe(), or declare a manual type.

🔄 unionOneOf 🔄 enforceDiscriminatedUnionComponents 🔄 defaultDateSchema

These options are replaced by the new override system (see below)

🛠️ New Override System

Direct property setting:

This is native to Zod v4 and allows you to set properties directly on the schema.

z.date().meta({
  type: 'string',
  format: 'date-time',
});

Note: Zod's native type representation takes precedence over the fields set in the meta() method. You can provide an override object or function to override this behavior.

Simple example:

z.string().meta({
  override: {
    type: number,
  },
});

Advanced customization with functions:

// Example: achieving unionOneOf behavior
z.union([z.string(), z.number()]).meta({
  override: ({ jsonSchema }) => {
    jsonSchema.oneOf = jsonSchema.anyOf;
    delete jsonSchema.anyOf;
  },
});

Global overrides:

import { createDocument } from 'zod-openapi';

createDocument(document, {
  override: ({ jsonSchema, zodSchema, io }) => {
    const def = zodSchema._zod.def;
    if (def.type === 'date' && io === 'output') {
      jsonSchema.type = 'string';
      jsonSchema.format = 'date-time';
    }
    if (def.type === 'union') {
      jsonSchema.oneOf = jsonSchema.anyOf;
      delete jsonSchema.anyOf;
    }
  },
});

Important: Only an override function can be provided in CreateDocumentOptions. Schema-level override will always run after the global function.

✨ New Options

🔄 cycles, reused

These options are passed directly to Zod's toJSONSchema() method to control how cycles and reused schemas are handled.

See Zod's JSON Schema documentation for details.

🧩 allowEmptySchema

Controls when empty schemas can be created. This controls if zod-openapi will throw an error when an empty schema is encountered.

  • { [zodType]: true } — Allow for both input and output
  • { [zodType]: { input: true, output: true } } — Allow for both input and output
  • { [zodType]: { input: true } } — Allow for input only
  • { [zodType]: { output: true } } — Allow for output only

🔁 Changed Options

🔄 refTypeunusedIO

Renamed to better reflect its purpose: specifying how unused component schemas should be rendered.

📋 Create Schema Options

🔄 schemaTypeio

Renamed to align with Zod's native terminology.

🔄 componentRefPathschemaComponentRefPath

Renamed for clarity and consistency.

🔄 componentsschemaComponents

Renamed for clarity and consistency.

🧩 New Components

PathItems, SecuritySchemes, Links, and Examples can now be created as reusable components

🔌 zod-openapi/api Changes

See ./api.md for detailed API changes in v5.