Migration: @kubb/plugin-zod
Part of the v4 → v5 migration guide. See the full option reference in @kubb/plugin-zod.
Zod v3 no longer supported
The version option ('3' | '4') is removed. v5 always generates Zod v4 schemas.
Upgrade your zod dependency:
bun add zod@^4pnpm add zod@^4npm install zod@^4yarn add zod@^4Removed: mapper
v5 removes mapper with no drop-in replacement. Customize schema naming through the resolver option, or rewrite a schema before printing with macros.
Removed: typed
In v4, typed: true annotated each schema with a ToZod type from the matching @kubb/plugin-ts output. v5 removes the option. To keep a type next to each schema, use inferred: true, which exports a z.infer alias instead.
pluginZod({
typed: true,
inferred: true,
})Removed: wrapOutput
The wrapOutput callback only fired on object property values, so a top-level string, enum, or union was never wrapped. v5 removes it for a printer override, which targets any node type.
pluginZod({
wrapOutput: ({ output, schema }) => `${output}.openapi(${JSON.stringify({ description: schema.description })})`,
printer: {
nodes: {
object(node) {
return `${this.base(node)}.openapi(${JSON.stringify({ description: node.description })})`
},
},
},
})Removed: operations
The operations option is gone, so plugin-zod no longer emits an operations.ts file with the operations and paths maps. To rebuild it, add a small custom plugin that reuses the Zod resolver, so schema names stay in sync. See Creating plugins for the plugin API.
Show the operations rebuild plugin
import { ast, defineGenerator, definePlugin } from 'kubb/kit'
import { pluginZodName, type ResolverZod } from '@kubb/plugin-zod'
const operationSchemaType = `{
readonly request: z.ZodTypeAny | undefined
readonly parameters: {
readonly path: z.ZodTypeAny | undefined
readonly query: z.ZodTypeAny | undefined
readonly header: z.ZodTypeAny | undefined
}
readonly responses: {
readonly [status: number]: z.ZodTypeAny
readonly default: z.ZodTypeAny
}
readonly errors: {
readonly [status: number]: z.ZodTypeAny
}
}`
function renderKey(key: string): string {
if (/^\d+$/.test(key)) return key
if (/^[A-Za-z_$][\w$]*$/.test(key)) return key
return JSON.stringify(key)
}
function renderObject(value: unknown, pad: string): string {
if (value === null) return 'null'
if (typeof value !== 'object') return String(value)
const entries = Object.entries(value as Record<string, unknown>)
if (entries.length === 0) return '{}'
const inner = `${pad} `
const body = entries
.map(([key, val]) => {
const rendered = typeof val === 'string' ? val : renderObject(val, inner)
return `${inner}${renderKey(key)}: ${rendered}`
})
.join(',\n')
return `{\n${body}\n${pad}}`
}
function buildSchemaNames(node: ast.OperationNode, resolver: ResolverZod) {
const pathParam = node.parameters.find((p) => p.in === 'path')
const queryParam = node.parameters.find((p) => p.in === 'query')
const headerParam = node.parameters.find((p) => p.in === 'header')
const responses: Record<number | string, string> = {}
const errors: Record<number | string, string> = {}
for (const res of node.responses) {
const statusNum = Number(res.statusCode)
if (Number.isNaN(statusNum)) continue
const name = resolver.response.status(node, res.statusCode)
responses[statusNum] = name
if (statusNum >= 400) errors[statusNum] = name
}
responses['default'] = resolver.response.response(node)
return {
request: node.requestBody?.content?.[0]?.schema ? resolver.response.body(node) : null,
parameters: {
path: pathParam ? resolver.param.path(node, pathParam) : null,
query: queryParam ? resolver.param.query(node, queryParam) : null,
header: headerParam ? resolver.param.headers(node, headerParam) : null,
},
responses,
errors,
}
}
export const pluginZodOperations = definePlugin(() => ({
name: 'plugin-zod-operations',
hooks: {
'kubb:plugin:setup'(ctx) {
ctx.addGenerator(
defineGenerator({
name: 'zod-operations',
operations(nodes, gctx) {
const resolver = gctx.getResolver(pluginZodName)
const zodOptions = gctx.requirePlugin(pluginZodName).options ?? {}
const output = zodOptions.output ?? { path: 'zod' }
const group = zodOptions.group ?? undefined
const importPath = zodOptions.importPath ?? 'zod'
const operationsFile = resolver.file({ name: 'operations', extname: '.ts', root: gctx.root, output, group })
const transformed = nodes.filter(ast.isHttpOperationNode).map((node) => ({ node, data: buildSchemaNames(node, resolver) }))
const imports = transformed.flatMap(({ node, data }) => {
const names = [data.request, ...Object.values(data.responses), ...Object.values(data.parameters)].filter(Boolean) as Array<string>
const opFile = resolver.file({ name: node.operationId, extname: '.ts', tag: node.tags[0] ?? 'default', path: node.path, root: gctx.root, output, group })
return ast.factory.createImport({ name: names, path: opFile.path, root: operationsFile.path })
})
const operations: Record<string, unknown> = {}
const paths: Record<string, Record<string, string>> = {}
for (const { node, data } of transformed) {
operations[node.operationId] = data
paths[node.path] = { ...(paths[node.path] ?? {}), [node.method]: `operations[${JSON.stringify(node.operationId)}]` }
}
return [
ast.factory.createFile({
baseName: operationsFile.baseName,
path: operationsFile.path,
imports: [ast.factory.createImport({ name: ['z'], path: importPath, isTypeOnly: true }), ...imports],
sources: [
ast.factory.createSource({
name: 'OperationSchema',
isExportable: true,
isIndexable: true,
nodes: [ast.factory.createText(`export type OperationSchema = ${operationSchemaType}`)],
}),
ast.factory.createSource({
name: 'OperationsMap',
isExportable: true,
isIndexable: true,
nodes: [ast.factory.createText('export type OperationsMap = Record<string, OperationSchema>')],
}),
ast.factory.createSource({
name: 'operations',
isExportable: true,
isIndexable: true,
nodes: [ast.factory.createText(`export const operations = ${renderObject(operations, '')} as const`)],
}),
ast.factory.createSource({
name: 'paths',
isExportable: true,
isIndexable: true,
nodes: [ast.factory.createText(`export const paths = ${renderObject(paths, '')} as const`)],
}),
],
}),
]
},
}),
)
},
},
}))import { defineConfig } from 'kubb/config'
import { pluginTs } from '@kubb/plugin-ts'
import { pluginZod } from '@kubb/plugin-zod'
import { pluginZodOperations } from './operationsPlugin.ts'
export default defineConfig({
input: './petStore.yaml',
output: { path: './src/gen' },
plugins: [pluginTs(), pluginZod(), pluginZodOperations()],
})The custom plugin runs after pluginZod, so the per-operation schemas it imports already exist.
Renamed: transformers.name
resolver.name replaces transformers.name, covered in full by Override a resolver. The v4 transformers.schema callback maps to macros.
Moved to adapterOas
dateType, integerType, unknownType, and emptySchemaType moved to adapterOas. See Migration: @kubb/adapter-oas.
Changed: inferred type names end with Type
With inferred: true, the z.infer<typeof schema> alias now carries a SchemaType suffix. petSchema exports PetSchemaType instead of PetSchema.
In v4 the value and its inferred type differed only by casing (petSchema / PetSchema), so an all-uppercase name like SUV, URL, or API produced the same identifier for both. The barrel then re-exported it twice and failed with TS2300: Duplicate identifier. The Type suffix keeps them apart at any casing.
export const petSchema = z.object({
name: z.string(),
status: z.enum(['available', 'pending', 'sold']).optional(),
})
export type PetSchemaType = z.infer<typeof petSchema>
export type PetSchema = z.infer<typeof petSchema>Update any imports that referenced the old name:
import type { PetSchemaType } from './gen/zod/petSchema.ts'
import type { PetSchema } from './gen/zod/petSchema.ts'Generated output
Response schema names gain a Status<code> segment
Response schema names now include a Status<code> segment. listPets200Schema becomes listPetsStatus200Schema. Update any imports that referenced the old name.
import { listPets200Schema } from './gen/zod'
import { listPetsStatus200Schema } from './gen/zod'Chained syntax instead of functional wrappers
v5 prefers the chained Zod 4 syntax. .optional() sits at the end of the chain, right before .describe().
id: z.optional(z.int()),
shipDate: z.optional(z.iso.datetime()),
status: z.optional(z.enum(['placed', 'approved']).describe('Order Status')),
id: z.int().optional(),
shipDate: z.iso.datetime().optional(),
status: z.enum(['placed', 'approved']).optional().describe('Order Status'),The functional form (z.optional(...)) is now reserved for mini: true output, which imports from zod/mini.
Self-referencing getters only for true cycles
v4 wrapped almost every nested ref in a getter. v5 does so only when the schema is truly circular, meaning it references itself or its parent.
get category() {
return categorySchema.optional()
},
get tags() {
return z.array(tagSchema).optional()
},
category: categorySchema.optional(),
tags: z.array(tagSchema).optional(),
get parent() {
return z.array(petSchema).optional()
},