Skip to content

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:

shell
bun add zod@^4
shell
pnpm add zod@^4
shell
npm install zod@^4
shell
yarn add zod@^4

Removed: 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.

kubb.config.ts
diff
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.

kubb.config.ts
diff
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
typescript
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`)],
}), ], }), ] }, }), ) }, }, }))
typescript
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.

zod/petSchema.ts
diff
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:

Update imports
diff
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.

Update imports
diff
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().

Generated output
diff
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.

Diff
diff
 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()
  },