DocumentsHey API
Zod Schema Generation
Zod Schema Generation
Type
Topic
Status
Published
Created
May 19, 2026
Updated
May 19, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

Zod Schema Generation#

The zod plugin in @hey-api/openapi-ts generates Zod runtime validation schemas directly from an OpenAPI specification. It produces schemas for request parameters, responses, and reusable component definitions. The plugin lives at packages/openapi-ts/src/plugins/zod/ and is structured into version-specific subdirectories (v3/, v4/, mini/) plus a shared/ layer for cross-version logic .

Source Layout#

PathPurpose
zod/index.tsRe-exports defaultConfig, defineConfig, ZodResolvers, ZodPlugin
zod/shared/discriminated-union.tstryBuildDiscriminatedUnion() — core discriminated union logic
zod/v4/toAst/object.tsObject schema generation including empty-object/record branching
zod/v4/toAst/union.tsUnion schema generation for Zod 4
zod/config.ts, resolvers.ts, types.tsConfiguration, Resolvers API surface, type definitions

The public entry point (index.ts) re-exports only three top-level symbols; all implementation is internal.

Zod Version Targeting#

The plugin defaults to Zod 4 output. Set compatibilityVersion: 3 for Zod 3 or compatibilityVersion: 'mini' for Zod Mini. Mini uses z.intersection() in places where v3/v4 use .and() or .extend() because Mini schemas lack those methods on intersection types .

Discriminated Union Handling#

When an OpenAPI oneOf or anyOf schema includes a discriminator object, the plugin generates z.discriminatedUnion() instead of a plain z.union(). This was introduced in April 2026 . Before that, the same pattern produced z.union([z.object({ kind: z.literal('circle') }).and(zModelCircle), ...]).

Output pattern (Zod 3/4):

export const zCompositionWithOneOfDiscriminator = z.discriminatedUnion('kind', [
  zModelCircle.extend({ kind: z.literal('circle') }),
  zModelSquare.extend({ kind: z.literal('square') })
]);

How it works#

The entry point is tryBuildDiscriminatedUnion() in zod/shared/discriminated-union.ts :

  1. Reads parentSchema.discriminator?.propertyName from the IR — populated by the OpenAPI parser when a discriminator mapping is present .
  2. Iterates each union schema item; skips null/const: null entries.
  3. Requires each item to be a two-element logicalOperator: 'and' (intersection) pair: one part carries the discriminator literal, the other is the $ref to the member schema.
  4. Reads properties?.[discriminatorKey]?.const to get the literal discriminator value (optional chaining guards empty properties) .
  5. Returns DiscriminatedUnionData (discriminator key + member list) on success, or null to fall back to z.union().

Fallback to z.union()#

tryBuildDiscriminatedUnion() returns null — causing the caller to emit z.union() — in these cases :

  • No discriminator.propertyName on the parent schema.
  • Any member carries isIntersection: true in its ZodMeta (i.e., derived from an allOf composition). Zod's discriminatedUnion does not support intersection members, so the plugin falls back to z.union() with .and()/z.intersection() .
  • A lazy reference is involved (meta.hasLazy).
  • The discriminated value is undefined (property missing or empty).

Zod Mini behavior#

For compatibilityVersion: 'mini', all union members use z.intersection() throughout because Mini schemas don't expose .extend() on intersection types. This means discriminated unions with allOf bases always produce z.union([ z.intersection(...), ... ]) output in Mini mode .

Empty Object Handling#

The Problem (Issue #3914)#

An OpenAPI object schema with no properties (e.g., Empty: { type: object }) was being emitted as z.record(z.string(), z.unknown()). When this schema appeared as a discriminated union member, the plugin tried to call .extend() on the record — which Zod does not support — causing a TypeScript compile error :

// ❌ Generated (broken)
export const zEmpty = z.record(z.string(), z.unknown());
export const zTestResponse = z.discriminatedUnion('type', [
  zEmpty.extend({ type: z.literal('Empty') }), // z.record has no .extend()
  ...
]);

How the Fix Works#

Object generation (zod/v4/toAst/object.ts): baseNode() branches on additionalProperties. If the schema has additionalProperties but no explicit properties keys, it emits z.record(z.string(), <additionalType>). If neither is present it emits z.object({}). The record path sets isIntersection: true in ZodMeta to mark the schema as non-extensible.

Discriminated union guard (shared/discriminated-union.ts): tryBuildDiscriminatedUnion() checks (refPart.symbolRef.meta as ZodMeta)?.isIntersection and returns null if the flag is set. This forces the union to fall back to z.union(), avoiding any .extend() call on a record .

Empty object .pretty() timing (Mar 4, 2026 refactor): $.object() is created without .pretty() upfront; .pretty() is called only when adding properties via node.pretty().prop(). This prevents an empty z.object({}) from being unnecessarily expanded in the output .