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 { , ,  } 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`)],
                  }),
                ],
              }),
            ]
          },
        }),
      )
    },
  },
}))
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()
  },