fix(zod): generate z.discriminatedUnion for multi-value discriminator mappings#3857
fix(zod): generate z.discriminatedUnion for multi-value discriminator mappings#3857CallumJHays wants to merge 10 commits into
Conversation
… mappings
When an OpenAPI oneOf discriminator maps multiple values to the same schema
(e.g. mapping: { one: '#/Bar', two: '#/Bar' }), the zod plugin was falling
back to z.union() instead of emitting z.discriminatedUnion().
Root cause: tryBuildDiscriminatedUnion only read .const from the discriminator
property schema. When the spec uses enum: [one, two], the IR represents this
as { logicalOperator: 'or', items: [{ const: 'one' }, { const: 'two' }] },
so discriminatedValue was undefined and the function returned null.
Fix:
- discriminated-union.ts: collect enum-valued discriminator properties and
return values as an array when logicalOperator is 'or' with const items
- v3/v4/mini union.ts: emit z.enum([...]) when discriminatedValue is an array,
z.literal(...) otherwise (single-value behaviour unchanged)
- Add discriminator-mapped-many test cases to zod v3/v4 test suites
|
|
|
@CallumJHays is attempting to deploy a commit to the Hey API Team on Vercel. A member of the Team first needs to authorize it. |
🦋 Changeset detectedLatest commit: 49dacb0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
TL;DR — Fixes the Key changes
Summary | 17 files | 1 commit | base: Multi-value discriminator mappings now take the discriminated path
The fallback was triggered by a single line —
|
|
Sorry about this. Claude ran a little wild, will re-raise after more careful review. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3857 +/- ##
==========================================
- Coverage 39.58% 39.54% -0.04%
==========================================
Files 532 532
Lines 19581 19598 +17
Branches 5835 5837 +2
==========================================
Hits 7751 7751
- Misses 9582 9593 +11
- Partials 2248 2254 +6
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Note
Fix is sound and minimal. The new logicalOperator: 'or' branch in tryBuildDiscriminatedUnion correctly matches the IR shape synthesized by packages/shared/src/openApi/3.{0,1}.x/parser/schema.ts when valueSchemas.length > 1 under a mapping, and identifiers.enum is already declared in plugins/zod/constants.ts so no import edits were needed. Ordering is preserved (Object.entries(mapping) → parser items array → items.map(item => item.const)), and z.enum([...]) is a valid discriminator member in zod v3, v4, and v4-mini.
One minor optional suggestion inline regarding test matrix symmetry. Not a blocker.
@hey-api/codegen-core
@hey-api/json-schema-ref-parser
@hey-api/nuxt
@hey-api/openapi-ts
@hey-api/shared
@hey-api/spec-types
@hey-api/types
@hey-api/vite-plugin
commit: |
Closes the only missing cell (v3 × 3.0.x) flagged by the PR reviewer.
96915a5 to
f327fb2
Compare
… mappings
When a discriminator maps multiple values to the same schema (e.g.
mapping: { one: '#/Bar', two: '#/Bar' }), tryBuildDiscriminatedUnion()
returned null because it only read .const from the discriminator property.
The IR stores multiple values as { logicalOperator: 'or', items: [...] },
which the function didn't handle, causing a silent fallback to z.union.
- Detect the logicalOperator: 'or' pattern and collect const values into
an array
- Emit z.enum([...]) for multi-value members, z.literal(...) for single
- Add discriminator-mapped-many test scenario across all matrix cells
(zod v3 + v4 × OpenAPI 3.0.x + 3.1.x)
- Fix changeset scope to plugin(zod) per contributing guide
f327fb2 to
aa5a2e3
Compare
There was a problem hiding this comment.
Important
Consider handling non-string discriminator types. The new z.enum(...) emission path works for string discriminators (the headline case) but produces invalid generated code when the discriminator is a boolean, integer, or number with multiple mapping values pointing to the same schema — see the inline comment.
TL;DR — Extends the zod plugin so OpenAPI discriminators that map multiple values to the same schema now produce z.discriminatedUnion(...) with z.enum([...]) for the multi-value member, instead of falling back to z.union(...). Restores discriminator-aware narrowing in the generated zod schema.
Key changes
- Detect multi-value
ordiscriminator schemas in the shared builder —tryBuildDiscriminatedUnionnow collects everyconstfrom alogicalOperator: 'or'group into an array, in addition to the single-constcase. - Branch on array vs scalar in each dialect emitter —
v3,v4, andminiunion.tsemitz.enum(values)whendiscriminatedValueis an array andz.literal(value)otherwise. - Add
discriminator-mapped-manyscenario across the test matrix — present in all four cells (zod v3/v4 × OpenAPI 3.0.x/3.1.x), with snapshots for thev3,v4, andminidialects.
Summary | 21 files | 3 commits | base: main ← fix/zod-discriminated-union-multi-value
Multi-value discriminator now stays narrowable
Before: multi-value mapping fell through to
z.union([...]), losing discriminator-aware narrowing.
After: emitsz.discriminatedUnion('foo', [zBar.extend({ foo: z.enum(['one', 'two']) }), ...]).
The IR shape from the parser for this case is { logicalOperator: 'and', items: [{ properties: { foo: { logicalOperator: 'or', items: [{ const: 'one' }, { const: 'two' }] } } }, <ref>] }. The previous tryBuildDiscriminatedUnion only read discriminatorProp.const, returned null, and the call site fell back to z.union.
packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts · v3/toAst/union.ts · v4/toAst/union.ts · mini/toAst/union.ts
Test matrix coverage
Before: zod
v3/test/3.0.x.test.tshad no discriminator scenarios and the new bug had no fixture in any of the four cells.
After:discriminator-mapped-manyis exercised in all four cells, each withv3/v4/minisnapshots (12 snapshots total). No pre-existing snapshots were regenerated.
zod/v3/test/3.0.x.test.ts · zod/v3/test/3.1.x.test.ts · zod/v4/test/3.0.x.test.ts · zod/v4/test/3.1.x.test.ts
Claude Opus | 𝕏
…many Add OpenAPI specs and test scenarios for discriminator mappings where multiple integer values map to the same schema. The generated snapshots intentionally contain `z.enum([1, 2])` which is invalid — z.enum only accepts string tuples — demonstrating the type-safety gap fixed in the next commit.
…inators z.enum only accepts [string, ...string[]], so emitting z.enum([1, 2]) for integer discriminator values is a TypeScript type error. When a discriminated union member maps to multiple non-string values, emit z.union([z.literal(v1), z.literal(v2)]) instead. Fixes the array branch in v3, v4, and mini union emitters.
…er literal in v3 Zod v3's getDiscriminator does not handle ZodUnion, so emitting z.union([z.literal(a), z.literal(b)]) as a discriminator branch throws at module load. Expand array-valued (non-string) discriminator members into one entry per literal in v3/toAst/union.ts. Apply the same fix in v4/toAst/union.ts when compatibilityVersion === 3 (which imports from zod/v3). Update affected snapshots accordingly.
There was a problem hiding this comment.
Important
Consider simplifying v4/toAst/union.ts — the expandForV3 branch and its rationale comment are dead code given the plugin.ts dispatch.
TL;DR — Generates z.discriminatedUnion (with mixed z.enum/z.literal/z.union branches) when an OpenAPI discriminator maps multiple values to the same schema, instead of falling back to z.union. Restores discriminator-aware narrowing across all three zod dialects (v3, v4, mini) and both OpenAPI versions (3.0.x, 3.1.x).
Key changes
- Detect
or-of-consts in the discriminator IR —tryBuildDiscriminatedUnionnow recognises the{ logicalOperator: 'or', items: [{ const: A }, { const: B }, ...] }shape that the parser synthesises when multiple mapping values point to one schema. - Per-dialect AST emission — new
buildDiscriminatorExpression(z, value)helper picksz.literal(x)for scalars,z.enum([...])for all-string arrays, andz.union([z.literal(...), ...])for mixed-type arrays. v3non-string expansion — zod v3'sgetDiscriminatordoes not acceptZodUnion, sov3/toAst/union.tsexpands non-string multi-value members into one branch per literal.- Test matrix symmetry — adds
discriminator-mapped-manyanddiscriminator-mapped-many-numberscenarios to all fourzod/v{3,4}/test/3.{0,1}.x.test.tscells, closing the previously-sparsezod/v3/3.0.xcell, with 24 new snapshots across the three dialect subdirs.
Summary | 35 files | 8 commits | base: main ← fix/zod-discriminated-union-multi-value
IR pattern detection in tryBuildDiscriminatedUnion
Before: Read only
discriminatorProp.const, returnednullfor any multi-value mapping → fell back to plainz.unionand lost discriminator-aware narrowing.
After: Also matcheslogicalOperator: 'or'of all-constitems, collecting values into an array passed downstream asdiscriminatedValue.
The guard discriminatorProp.items?.every(item => item.const !== undefined) is correctly defensive — convertDiscriminatorValue only ever returns {const, type} today, but a future IR change yielding non-const items now degrades cleanly to z.union rather than mis-emitting a broken z.discriminatedUnion.
Per-dialect emission strategies
Before: All three dialects emitted
z.literal(member.discriminatedValue)unconditionally — wrong for arrays.
After:miniandv4rely onbuildDiscriminatorExpression;v3additionally expands non-string arrays into one branch per literal becauseZodUnionis not a valid v3 discriminator member.
The v3 expansion is sound: Bar.extend({ code: z.literal(1) }) shape-merges (replacing the original code field), so the per-literal branches are strictly narrower than the original Bar and validate real data correctly. All-string arrays use z.enum([...]) which v3's getDiscriminator whitelists.
Why does v4 not need expansion?
Zod v4's
$ZodDiscriminatedUnionderives per-branch values viaoption._zod.values, andz.union([z.literal(1), z.literal(2)])exposes{1, 2}throughflatMapof its options' value sets — so aZodUnionof literals is a valid discriminator branch in v4 and v4-mini. v3'sgetDiscriminatorpredates this and only whitelistsZodLiteral/ZodEnum/ZodNativeEnumplus a small set of wrappers.
v3/toAst/union.ts · v4/toAst/union.ts · mini/toAst/union.ts
Test matrix and snapshot coverage
Before:
discriminator-mapped-manywas missing fromzod/v3/test/3.0.x.test.ts(the previously-sparse cell); no-numbervariant existed anywhere.
After: Both scenarios present in all four test cells with byte-identical descriptions; 24 new snapshots cover the full2 scenarios × 4 cells × 3 dialectsmatrix.
The harness remains snapshot-only — there is no runtime parse() assertion exercising the new z.discriminatedUnion output. That's a pre-existing characteristic of the suite, not a regression, but worth noting given the bug class this PR targets is precisely the class snapshot diffing cannot catch.
zod/v3/test/3.0.x.test.ts · zod/v4/test/3.1.x.test.ts · specs/3.0.x/discriminator-mapped-many-number.yaml
Claude Opus | 𝕏
Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Note
Three independent lenses (correctness, research-validated zod semantics, test integrity) found no blocking issues. Cross-checking against the upstream getDiscriminator (zod v3) and $ZodDiscriminatedUnion / propValues flattening (zod v4 + mini) confirms each per-dialect emission is valid.
TL;DR — Fixes a regression where multi-value discriminator mappings (mapping: { one: Bar, two: Bar }) fell back to z.union() in the zod plugin. The fix detects the IR's logicalOperator: 'or' of const-items shape, emits z.discriminatedUnion(...), and threads dialect-specific branch shapes (z.literal / z.enum / z.union of literals) so the output respects each zod version's getDiscriminator rules.
Key changes
- Detect multi-value discriminator IR shape — extend
tryBuildDiscriminatedUnionto recognise{ logicalOperator: 'or', items: [{ const: ... }, ...] }in addition to single-constproperties. - Add
buildDiscriminatorExpressionhelper — emitsz.literal(v)for scalars,z.enum([...])for all-string arrays, andz.union([z.literal(...), ...])for non-string arrays. - v3 dialect splits non-string multi-value into one branch per literal — required because zod v3's
getDiscriminatoronly acceptsZodLiteral/ZodEnum/ZodNativeEnum, notZodUnion. - v4 and mini emit
z.unionof literals directly —$ZodDiscriminatedUnionflattens throughoption._zod.valuessoZodUnionof literals is a valid branch. - Test matrix expanded symmetrically — both
discriminator-mapped-many(all-strings) anddiscriminator-mapped-many-number(integers) added to all four cells (zod v3/v4 × OpenAPI 3.0/3.1) with 24 new snapshots across the three dialect subdirs.
Summary | 35 files | 10 commits | base: main ← fix/zod-discriminated-union-multi-value
Per-dialect branch shapes
Before:
z.union([...])fallback whenevermappinghad multiple keys pointing at the same schema, losing discriminator-aware narrowing entirely.
After:z.discriminatedUnion(key, [...])with branches shaped to each zod version's accepted discriminator types.
The dialect split is the load-bearing detail. For numeric discriminators with values [1, 2] mapped to Bar:
| Dialect | Branch emission | Why |
|---|---|---|
v3 (zod 3.x) |
zBar.extend({ code: z.literal(1) }), zBar.extend({ code: z.literal(2) }) |
getDiscriminator only walks ZodLiteral/ZodEnum/ZodNativeEnum; ZodUnion falls through and throws at construction. |
v4 (zod 4.x classic) |
zBar.extend({ code: z.union([z.literal(1), z.literal(2)]) }) |
$ZodUnion._zod.values flatMaps option values, so propValues[code] = {1, 2} registers both keys against one option. |
mini (zod 4.x mini) |
z.extend(zBar, { code: z.union([z.literal(1), z.literal(2)]) }) |
ZodMiniDiscriminatedUnion delegates to the v4 core constructor, inheriting the same flattening. |
For all-string arrays, every dialect collapses to a single branch with z.enum([...]) (valid in v3 via ZodEnum, in v4/mini via _zod.values). Single-value mappings remain z.literal(v) and were not affected.
shared/discriminated-union.ts · v3/toAst/union.ts · v4/toAst/union.ts · mini/toAst/union.ts
Notes that did not warrant inline anchors
- Mixed-type multi-value branch in
buildDiscriminatorExpressionis unreachable from the IR. The doc comment lists[1, 2, "x"] → z.union([z.literal(1), z.literal(2), z.literal("x")])as the third case, butfindDiscriminatorPropertyTyperesolves a singlepropertyTypeper discriminator beforeconvertDiscriminatorValueruns, so all entries in the resultingor-of-consts share one type. Defensive only — the branch still produces correct output if the IR ever changes. - Behavioural asymmetry between string and number multi-value snapshots in v3. All-string members emit one combined branch (
z.enum(['one','two'])); all-number members emit N separate branches (z.literal(1),z.literal(2)). Functionally equivalent at parse time but produces a slightly wider inferred output type for numbers. Forced by v3'sZodEnum-only constraint and acknowledged by the inline comment. valibotplugin was not updated.packages/openapi-ts/src/plugins/valibot/shared/discriminated-union.tsstill reads only.constand falls back tounionfor multi-value mappings. Out of scope for this PR but a candidate follow-up if symmetry across plugins is desired.
Claude Opus | 𝕏

Summary
mapping: { one: '#/components/schemas/Bar', two: '#/components/schemas/Bar' }), the zod plugin was falling back toz.union()instead of emittingz.discriminatedUnion().tryBuildDiscriminatedUnion()only read.constfrom the discriminator property schema. When the IR stores multiple values, it uses{ logicalOperator: 'or', items: [{ const: 'one' }, { const: 'two' }] }— a pattern the function didn't handle, causing it to returnnulland fall back toz.union.discriminated-union.tsto detect thelogicalOperator: 'or'pattern and collect allconstvalues into an array. Extended union AST builders inv3,v4, andminito emitz.enum([...])for multi-value members andz.literal(...)for single-value members.Relates to #1986, which fixed this at the parser/TypeScript output layer — this PR extends the same fix to the zod plugin layer.
Changes
packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts— handle multi-value discriminator propertiespackages/openapi-ts/src/plugins/zod/v3/toAst/union.ts— emitz.enumvsz.literalbased on value countpackages/openapi-ts/src/plugins/zod/v4/toAst/union.ts— samepackages/openapi-ts/src/plugins/zod/mini/toAst/union.ts— samediscriminator-mapped-manytest scenario to zod v3 and v4 suites (OpenAPI 3.0.x and 3.1.x)z.discriminatedUnionoutput with mixedz.enum/z.literalmembersExample
Input (
discriminator-mapped-many.yaml):Before (incorrect — discriminator-aware narrowing lost, multi-value member expanded inline):
After (correct — discriminator-aware narrowing preserved):
Test plan
pnpm build --filter="@hey-api/**"passespatchfor@hey-api/openapi-ts