Open Studio
Migration

Migration: @kubb/plugin-zod

Configuration and generated-output changes for @kubb/plugin-zod when migrating from Kubb v4 to v5.

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