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#
| Path | Purpose |
|---|---|
zod/index.ts | Re-exports defaultConfig, defineConfig, ZodResolvers, ZodPlugin |
zod/shared/discriminated-union.ts | tryBuildDiscriminatedUnion() — core discriminated union logic |
zod/v4/toAst/object.ts | Object schema generation including empty-object/record branching |
zod/v4/toAst/union.ts | Union schema generation for Zod 4 |
zod/config.ts, resolvers.ts, types.ts | Configuration, 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 :
- Reads
parentSchema.discriminator?.propertyNamefrom the IR — populated by the OpenAPI parser when adiscriminatormapping is present . - Iterates each union schema item; skips
null/const: nullentries. - Requires each item to be a two-element
logicalOperator: 'and'(intersection) pair: one part carries the discriminator literal, the other is the$refto the member schema. - Reads
properties?.[discriminatorKey]?.constto get the literal discriminator value (optional chaining guards empty properties) . - Returns
DiscriminatedUnionData(discriminator key + member list) on success, ornullto fall back toz.union().
Fallback to z.union()#
tryBuildDiscriminatedUnion() returns null — causing the caller to emit z.union() — in these cases :
- No
discriminator.propertyNameon the parent schema. - Any member carries
isIntersection: truein itsZodMeta(i.e., derived from anallOfcomposition). Zod'sdiscriminatedUniondoes not support intersection members, so the plugin falls back toz.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 .