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 { , , } from 'kubb/kit'
import { , type } from '@kubb/plugin-zod'
const = `{
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 (: string): string {
if (/^\d+$/.()) return
if (/^[A-Za-z_$][\w$]*$/.()) return
return .()
}
function (: unknown, : string): string {
if ( === null) return 'null'
if (typeof !== 'object') return ()
const = .( as <string, unknown>)
if (. === 0) return '{}'
const = `${} `
const =
.(([, ]) => {
const = typeof === 'string' ? : (, )
return `${}${()}: ${}`
})
.(',\n')
return `{\n${}\n${}}`
}
function (: ., : ) {
const = ..(() => . === 'path')
const = ..(() => . === 'query')
const = ..(() => . === 'header')
const : <number | string, string> = {}
const : <number | string, string> = {}
for (const of .) {
const = (.)
if (.()) continue
const = ..(, .)
[] =
if ( >= 400) [] =
}
['default'] = ..()
return {
: .?.?.[0]?. ? ..() : null,
: {
: ? ..(, ) : null,
: ? ..(, ) : null,
: ? ..(, ) : null,
},
,
,
}
}
export const = (() => ({
: 'plugin-zod-operations',
: {
'kubb:plugin:setup'() {
.(
({
: 'zod-operations',
(, ) {
const = .()
const = .(). ?? {}
const = . ?? { : 'zod' }
const = . ??
const = . ?? 'zod'
const = .({ : 'operations', : '.ts', : ., , })
const = .(.).(() => ({ , : (, ) }))
const = .(({ , }) => {
const = [., ....(.), ....(.)].() as <string>
const = .({ : ., : '.ts', : .[0] ?? 'default', : ., : ., , })
return ..({ : , : ., : . })
})
const : <string, unknown> = {}
const : <string, <string, string>> = {}
for (const { , } of ) {
[.] =
[.] = { ...([.] ?? {}), [.]: `operations[${.(.)}]` }
}
return [
..({
: .,
: .,
: [..({ : ['z'], : , : true }), ...],
: [
..({
: 'OperationSchema',
: true,
: true,
: [..(`export type OperationSchema = ${}`)],
}),
..({
: 'OperationsMap',
: true,
: true,
: [..('export type OperationsMap = Record<string, OperationSchema>')],
}),
..({
: 'operations',
: true,
: true,
: [..(`export const operations = ${(, '')} as const`)],
}),
..({
: 'paths',
: true,
: true,
: [..(`export const 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()
},