Beta You're reading the docs for Kubb v5, which is currently in beta. View the stable v4 docs
Skip to content
Tags
zodvalidationschemaruntime-validationcodegenopenapi
Details
  • Updated today
  • Created 2 years ago
Official v5.0.0-beta.74 MIT kubb >=5.0.0 node >=22

@kubb/plugin-zod

Generate Zod v4 schemas from OpenAPI for runtime validation that stays in sync with your TypeScript types.

Downloads
522k / mo
Stars
3
Bundle size
382.2 kB
Updated
today

@kubb/plugin-zod

@kubb/plugin-zod turns your OpenAPI schemas into Zod v4 schemas. Use them to validate API responses at runtime, build form schemas, or feed router libraries that take Zod (tRPC, Hono, Elysia).

Pair it with a client plugin (@kubb/plugin-axios or @kubb/plugin-fetch) and set the client's parser: 'zod' to validate every response.

Installation

shell
bun add -d @kubb/plugin-zod@beta
shell
pnpm add -D @kubb/plugin-zod@beta
shell
npm install --save-dev @kubb/plugin-zod@beta
shell
yarn add -D @kubb/plugin-zod@beta

Options

NOTE

Schema-shaping options such as enum, dateType, integerType, unknownType, emptySchemaType, enumSuffix, and contentType moved to @kubb/adapter-oas in v5. Set them with adapterOas({ ... }) instead of on this plugin.

output

Where the generated Zod schemas are written and how they are exported.

Type: Output
Required: false
Default: { path: 'zod', barrel: { type: 'named' } }

output.path

Folder where the plugin writes its generated code, resolved against the global output.path set on defineConfig. To put everything in one file instead, set output.mode: 'file' and point path at a target file including its extension (e.g. 'types.ts').

Type: string
Required: true
Default: 'zod'

TIP

output.path sets where files go. output.mode sets how many. Use 'directory' (the default) for one file per operation, optionally grouped into subdirectories with the group option. Use 'file' to write everything into a single file.

output.mode

How the plugin consolidates its generated code into files.

  • 'directory' writes one file per operation or schema under output.path. This is the default.
  • 'file' writes everything into a single file. The output.path must include the file extension (e.g. 'types.ts', 'models.py').
Type: 'directory' | 'file'
Required: false
Default: 'directory'

TIP

Pair 'directory' with the group option to organize output into per-tag or per-path subdirectories. mode: 'file' forbids group, since a single-file output has nothing to group. Combining them stops the build with a KUBB_INVALID_PLUGIN_OPTIONS error.

output.barrel

Controls how the generated index.ts (barrel) file re-exports the plugin's output.

  • { type: 'named' } re-exports each symbol by name. Best for tree-shaking and explicit imports.
  • { type: 'all' } uses export *. Smaller barrel file, but exports everything.
  • { nested: true } creates a barrel in every subdirectory, so callers can import from any depth.
  • false skips the barrel entirely. The plugin's files are also excluded from the root index.ts.
Type: { type: 'named' | 'all', nested?: boolean } | false
Required: false
Default: { type: 'named' }

TIP

Pick 'named' when consumers care about which symbols they import (better tree-shaking, friendlier auto-import). Pick 'all' when the file count is small and you want a one-line barrel.

typescript
// src/gen/zod/index.ts
export { petSchema, petStatusSchema } from './petSchema'
export { storeSchema } from './storeSchema'
typescript
// src/gen/zod/index.ts
export * from './petSchema'
export * from './storeSchema'
text
src/gen/zod/
├── index.ts             # re-exports ./pet and ./store
├── pet/
│   ├── index.ts         # re-exports petSchema, ...
│   └── petSchema.ts
└── store/
    ├── index.ts
    └── storeSchema.ts
text
# No index.ts is generated for this plugin.
# Its files are also excluded from the root index.ts.

output.banner

Text prepended to every generated file, for license headers, lint disables, or @ts-nocheck directives. Pass a string for a static banner, or a function to compute it from each file's RootNode (the AST root holding path, schema, and operation context).

Type: string | ((node: RootNode) => string)
Required: false

A static banner: '/* eslint-disable */\n// @ts-nocheck' lands at the top of each generated file:

typescript
/* eslint-disable */
// @ts-nocheck
import * as z from 'zod'

export const petSchema = z.object({
  name: z.string(),
})

A function banner builds the text from the file's RootNode, such as banner: (node) => \// Source: ${node.filePath}``.

Text appended to every generated file. Mirrors banner, for closing comments, re-enabling lint rules, or marker lines. Pass a string or a function that receives the file's RootNode and returns the footer text. Pair banner: '/* eslint-disable */' with footer: '/* eslint-enable */' to scope a lint disable to the generated file.

Type: string | ((node: RootNode) => string)
Required: false

resolver

Changes how the plugin names generated files and symbols. Use it to add a prefix or suffix, or to swap the casing, without forking the plugin. Override only the methods you want to change. Anything you omit, or that returns null or undefined, falls back to the default. Inside a method, this is the full resolver, so you can call this.default(name, 'function') to reuse the built-in name.

Type: Partial<ResolverZod> & ThisType<ResolverZod>
Required: false

TIP

Use resolver for naming and file-location tweaks. For changing the AST nodes themselves (e.g. stripping descriptions), use macros instead.

For example, resolver: { resolveSchemaName(name) { return \${this.default(name, 'function')}Validator` } }renames every generated schema frompetSchematopetValidator`.

Each plugin ships with a default resolver:

Plugin Default resolver
@kubb/plugin-ts resolverTs
@kubb/plugin-zod resolverZod
@kubb/plugin-faker resolverFaker
@kubb/plugin-cypress resolverCypress
@kubb/plugin-msw resolverMsw
@kubb/plugin-mcp resolverMcp
@kubb/plugin-axios resolverClient
@kubb/plugin-fetch resolverClient

group

Splits generated files into subfolders by the operation's tag or URL path. Each group gets its own directory under {output.path}/{groupName}/. Without group, every file lands directly in output.path.

Type: Group
Required: false

TIP

Use group to mirror your API's domain structure (pet, store, user) in the generated code. Combine it with output.barrel: { type: 'named', nested: true } to get per-tag barrel files.

group only applies to output.mode: 'directory' (the default). It is not valid with output.mode: 'file', since a single-file output has no grouping concept.

With group: { type: 'tag' }, the generator emits one folder per tag, named after the camelCased tag:

Resulting tree
text
src/gen/
├── pet/
│   ├── addPetSchema.ts
│   └── getPetSchema.ts
└── store/
    ├── createStoreSchema.ts
    └── getStoreByIdSchema.ts

Pass group.name to customize the folder name. For example, a name function that appends Controller to the group keeps the pre-v5 petController/ layout.

group.type

Property used to assign each operation to a group. Required whenever group is set.

  • 'tag' uses the operation's first tag (operation.getTags().at(0)?.name).
  • 'path' uses the first segment of the operation's URL, such as pet for /pet/{petId}.

Operations with no tag are placed in a default group.

Type: 'tag' | 'path'
Required: true

NOTE

Required: true* is conditional. It only applies when the parent group option is used. group itself stays optional.

group.name

Function that builds the folder/identifier name from a group key (the operation's first tag).

Type: (context: GroupContext) => string
Required: false
Default: (ctx) => \${ctx.group}``

importPath

Module specifier for the import { z } from '...' statement at the top of every generated file. Set it to re-export Zod from your own module. It defaults to 'zod', or to 'zod/mini' when the mini option is on.

Type: string
Required: false
Default: mini ? 'zod/mini' : 'zod'
typescript
import * as z from 'zod'
typescript
import * as z from 'zod/mini'
typescript
// importPath: '@acme/zod'
import { z } from '@acme/zod'

You consume the schema the same way no matter where z comes from. The import line only changes inside the generated files:

typescript
import { petSchema } from './src/gen/zod/petSchema'

const pet = petSchema.parse(data)

NOTE

The 'zod' and 'zod/mini' modules import the z namespace (import * as z). A custom module imports the named z export (import { z }), so re-export z from there.

typed

Ties each Zod schema to its TypeScript type from @kubb/plugin-ts. With typed: true, the generated petSchema is typed as ToZod<Pet>. TypeScript then fails to compile when the schema drifts from the type. This needs @kubb/plugin-ts in the plugins list.

Type: boolean
Required: false
Default: false

IMPORTANT

The mapping uses a ToZod-style helper (vendored in Kubb) to derive a Zod shape from a TypeScript type.

typescript
import * as z from 'zod'
import type { ToZod } from '@kubb/plugin-zod'
import type { Pet } from '../ts/Pet'

export const petSchema: ToZod<Pet> = z.object({
  name: z.string(),
  status: z.enum(['available', 'pending', 'sold']).optional(),
})
typescript
import * as z from 'zod'

export const petSchema = z.object({
  name: z.string(),
  status: z.enum(['available', 'pending', 'sold']).optional(),
})

You consume the schema the same way whether or not it is typed. typed only adds the compile-time ToZod<Pet> check on the schema declaration:

typescript
import { petSchema } from './src/gen/zod/petSchema'

const pet = petSchema.parse(data)

inferred

Exports a z.infer<typeof schema> type alias next to every generated schema. The Zod schema becomes the single source of truth, so you do not import types from @kubb/plugin-ts. The alias is the PascalCased schema name with a SchemaType suffix (petSchema becomes PetSchemaType). The value and its inferred type never share a name, even for all-uppercase names like SUV or URL.

Type: boolean
Required: false
Default: false
typescript
import * as z from 'zod'

export const petSchema = z.object({
  name: z.string(),
})

export type PetSchemaType = z.infer<typeof petSchema>
typescript
import * as z from 'zod'

export const petSchema = z.object({
  name: z.string(),
})

Where the type comes from depends on the value:

typescript
import { petSchema, type PetSchemaType } from './src/gen/zod/petSchema'

const pet: PetSchemaType = petSchema.parse(data)
typescript
import * as z from 'zod'
import { petSchema } from './src/gen/zod/petSchema'

type Pet = z.infer<typeof petSchema>
const pet: Pet = petSchema.parse(data)

coercion

Wraps schemas in z.coerce so input is coerced to the expected type before validation. Use it for form data, query params, and any source where everything arrives as a string.

  • true coerces strings, numbers, and dates.
  • false (default) coerces nothing and validates strictly.
  • An object picks which primitives to coerce.

See Coercion for primitives.

Type: boolean | { dates?: boolean; strings?: boolean; numbers?: boolean }
Required: false
Default: false

TIP

When @kubb/adapter-oas runs with dateType: 'date' (date fields typed as Date), the generated schemas round-trip dates at the validation boundary instead of coercing. Response schemas decode the ISO string into a Date (z.iso.datetime().transform(...)), and an ${name}InputSchema variant encodes Date back into an ISO string (z.date().transform(...)) for request bodies. So coercion.dates has no effect on these fields.

typescript
z.coerce.string()
z.coerce.date()
z.coerce.number()
typescript
z.string()
z.date()
z.number()
typescript
// { numbers: true, strings: false, dates: false }
z.string()
z.date()
z.coerce.number()

Coercion changes what the schema accepts at parse:

typescript
import { querySchema } from './src/gen/zod/querySchema'

// strings, numbers, and dates are coerced
querySchema.parse({ count: '5', date: '2024-01-01' }) // { count: 5, date: Date }
typescript
import { querySchema } from './src/gen/zod/querySchema'

querySchema.parse({ count: '5', date: '2024-01-01' }) // throws a ZodError, nothing is coerced
querySchema.parse({ count: 5, date: new Date() }) // ok
typescript
import { querySchema } from './src/gen/zod/querySchema'

// only numbers are coerced, so the date must already be a Date
querySchema.parse({ count: '5', date: new Date() }) // { count: 5, date: Date }
querySchema.parse({ count: '5', date: '2024-01-01' }) // throws, the date is not coerced

operations

Emits an operations.ts file. It groups the schemas per operation: request body, path/query/header params, each response status, and errors. The map is keyed by operationId, and a paths map links each URL and method back to it. Use it when you wire Kubb output into a server framework that takes one set of Zod schemas per route.

Type: boolean
Required: false
Default: false
typescript
export const operations = {
  getPetById: {
    request: undefined,
    parameters: {
      path: getPetByIdPathParamsSchema,
      query: undefined,
      header: undefined,
    },
    responses: {
      200: getPetById200Schema,
    },
    errors: {},
  },
} as const

export const paths = {
  '/pet/{petId}': {
    get: operations['getPetById'],
  },
} as const
typescript
// No operations.ts is generated.

With operations: true, reach a route's schemas through the map and validate with them:

typescript
import { operations, paths } from './src/gen/zod/operations'

const params = operations['getPetById'].parameters.path.parse({ petId: '1' })
const pet = operations['getPetById'].responses[200].parse(data)

// or reach the same entry by URL and method
const getPet = paths['/pet/{petId}'].get

guidType

Validator used for OpenAPI properties with format: uuid.

  • 'uuid' (default) generates z.uuid(), a standard RFC 4122 UUID.
  • 'guid' generates z.guid(), which is looser and accepts Microsoft-style GUIDs.
Type: 'uuid' | 'guid'
Required: false
Default: 'uuid'
typescript
z.uuid()
typescript
z.guid()

You consume the schema the same way for both values. Only the accepted format changes, with 'uuid' stricter than 'guid':

typescript
import { idSchema } from './src/gen/zod/idSchema'

const id = idSchema.parse('123e4567-e89b-12d3-a456-426614174000')

regexType

Controls how an OpenAPI pattern is written inside .regex(...).

  • 'literal' (default) emits a regex literal, such as .regex(/^[a-z]+$/).
  • 'constructor' emits the RegExp constructor, such as .regex(new RegExp("^[a-z]+$")).

Reach for 'constructor' when a regex literal trips up your build pipeline or when you need the pattern as a plain string.

Type: 'literal' | 'constructor'
Required: false
Default: 'literal'
typescript
z.string().regex(/^[a-z]+$/)
typescript
z.string().regex(new RegExp('^[a-z]+$'))

Both forms validate identically at runtime, so you consume the schema the same way:

typescript
import { slugSchema } from './src/gen/zod/slugSchema'

const slug = slugSchema.parse('abc')

mini

Switches code generation to Zod Mini. Schemas use the functional API (z.optional(z.string())) instead of the chainable one (z.string().optional()). Bundlers can then tree-shake unused validators. Setting mini: true also defaults importPath to 'zod/mini'.

Type: boolean
Required: false
Default: false

TIP

Use Zod Mini in code that ships to the browser. The functional API drops several kilobytes from the bundle compared to the standard Zod build.

WARNING

Zod Mini is currently in beta. Its API may change in a future release.

typescript
import * as z from 'zod/mini'

z.optional(z.string())
z.nullable(z.number())
z.array(z.string()).check(z.minLength(1), z.maxLength(10))
typescript
import * as z from 'zod'

z.string().optional()
z.number().nullable()
z.array(z.string()).min(1).max(10)

You consume the schema the same way in either mode. Zod Mini only changes how the generated code builds the schema:

typescript
import { petSchema } from './src/gen/zod/petSchema'

const pet = petSchema.parse(data)

include

Generates only the operations that match at least one entry in the list. Everything else is skipped. Each entry filters by one of:

  • tag: the operation's first tag in the OpenAPI spec.
  • operationId: the operation's operationId.
  • path: the URL path, such as '/pet/{petId}'.
  • method: the HTTP method, such as 'get' or 'post'.
  • contentType: the request body media type, such as 'application/json'.
  • schemaName: the component schema name under #/components/schemas.

pattern accepts either a string (exact match) or a RegExp for fuzzy matches.

Type: Array<Include>
Required: false
Type definition
typescript
export type Include = {
  type: 'tag' | 'operationId' | 'path' | 'method' | 'contentType' | 'schemaName'
  pattern: string | RegExp
}

Pass include: [{ type: 'tag', pattern: 'pet' }] to keep only the pet tag. Stack entries to narrow further, such as { type: 'method', pattern: 'GET' } with { type: 'path', pattern: /^\/pet/ } for GET operations under /pet.

exclude

Skips any operation or schema that matches at least one entry in the list. It is the opposite of include. Entries use the same type (tag, operationId, path, method, contentType, schemaName) and pattern (string or RegExp). When both are set, exclude wins.

Type: Array<Exclude>
Required: false
Type definition
typescript
export type Exclude = {
  type: 'tag' | 'operationId' | 'path' | 'method' | 'contentType' | 'schemaName'
  pattern: string | RegExp
}

Pass exclude: [{ type: 'tag', pattern: 'store' }] to drop the store tag, or stack { type: 'operationId', pattern: 'deletePet' } with { type: 'method', pattern: 'DELETE' } to skip one operation and every DELETE.

override

Applies different plugin options to operations that match a pattern. Use it for the few endpoints that need special treatment. Each entry takes the same type and pattern as include and exclude, plus an options object. That object accepts any plugin option except override, so rules cannot nest. Entries run top to bottom. The first match merges onto the plugin defaults, and later entries do not stack.

Type: Array<Override>
Required: false
Type definition
typescript
export type Override = {
  type: 'tag' | 'operationId' | 'path' | 'method' | 'contentType' | 'schemaName'
  pattern: string | RegExp
  options: Omit<Partial<Options>, 'override'>
}

For example, override: [{ type: 'tag', pattern: 'user', options: { coercion: true } }] coerces input only for the user tag while the rest of the spec validates strictly.

macros

Rewrites AST nodes before they are printed to source. Use it to rename operation IDs, drop descriptions, or change schema metadata without forking the generator. Each macro callback (such as schema or operation) receives the node and a context object. Return a new node to replace it, or undefined to leave it as is. Callbacks you omit keep their default behavior. Macros run in order, so a later one sees the output of an earlier one.

Type: Array<Macro>
Required: false

TIP

Use macros to rewrite node properties before printing. For changing the names of generated symbols and files, use resolver instead.

Each entry names the macro and supplies one callback per node kind:

A macros array
typescript
import { pluginZod } from '@kubb/plugin-zod'

pluginZod({
  macros: [
    {
      name: 'strip-descriptions',
      schema(node) {
        return { ...node, description: undefined }
      },
    },
    {
      name: 'prefix-operation-id',
      operation(node) {
        return { ...node, operationId: `api_${node.operationId}` }
      },
    },
  ],
})

printer

Replaces the Zod handler for a specific schema type, such as 'integer', 'date', or 'string'. Each handler returns the Zod expression as a string.

When mini: true, overrides target the Zod Mini printer. Otherwise they target the standard Zod printer.

Type: { nodes?: PrinterZodNodes | PrinterZodMiniNodes }
Required: false
Map integers and dates to other Zod expressions
typescript
import { pluginZod } from '@kubb/plugin-zod'

pluginZod({
  printer: {
    nodes: {
      integer() {
        return 'z.number()'
      },
      date() {
        return 'z.string().date()'
      },
    },
  },
})

wrapOutput

Wraps the generated Zod schema string with extra calls before it is written to disk. The callback receives the raw schema string and its SchemaNode. Return a new string to replace the output, or undefined to leave it untouched.

Type: (arg: { output: string; schema: SchemaNode }) => string | undefined
Required: false

TIP

Use this to round-trip OpenAPI metadata back into Zod, such as examples, descriptions, or .openapi() annotations for libraries that re-emit OpenAPI from Zod schemas.

Append .openapi() with metadata
typescript
import { pluginZod } from '@kubb/plugin-zod'

pluginZod({
  wrapOutput: ({ output, schema }) => {
    if (!schema.examples?.length) {
      return undefined
    }

    return `${output}.openapi(${JSON.stringify({ examples: schema.examples })})`
  },
})

Dependencies

@kubb/plugin-zod pairs with @kubb/plugin-ts. Add pluginTs to the plugins list when typed: true, since the generated schemas reference the TypeScript types it produces. Without typed, the schemas stand alone, and inferred: true exports their z.infer types so you do not need pluginTs at all.

Example

typescript
import {  } from 'kubb'
import {  } from '@kubb/plugin-ts'
import {  } from '@kubb/plugin-zod'

export default ({
  : { : './petStore.yaml' },
  : { : './src/gen' },
  : [
    (),
    ({
      : { : './zod' },
      : { : 'tag', : ({  }) => `${}Schemas` },
      : true,
      : 'zod',
    }),
  ],
})

See Also