Documents
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 .