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 1 commit into
Conversation
|
|
|
@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: 9a5894e 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.05%
==========================================
Files 532 532
Lines 19581 19601 +20
Branches 5835 5839 +4
==========================================
Hits 7751 7751
- Misses 9582 9595 +13
- Partials 2248 2255 +7
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: |
96915a5 to
f327fb2
Compare
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 | 𝕏
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 | 𝕏
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 | 𝕏
7bcc24e to
6de0b51
Compare
There was a problem hiding this comment.
TL;DR — Extends the zod plugin to emit z.discriminatedUnion (instead of falling back to z.union) when an OpenAPI discriminator maps multiple values to the same schema. The IR-extraction and per-dialect emission strategies are sound; load-bearing claims about zod v3 / v4 / v4-mini getDiscriminator semantics check out against current upstream source.
Key changes
- Detect
or-of-consts intryBuildDiscriminatedUnion— extract a value array when the IR stores multiple discriminator values for one branch - Per-dialect emission via
buildDiscriminatorExpression— single value →z.literal, all-string array →z.enum, mixed/numeric array →z.union([z.literal, …]) - v3 branch expansion for non-string arrays —
[1, 2]becomes two separateextend({code: z.literal(1)})/extend({code: z.literal(2)})branches because v3getDiscriminatordoes not acceptZodUnion - New scenario
discriminator-mapped-many-number— added to all four cells of the zod test matrix (v3/v4 × 3.0.x/3.1.x) with the existing string scenario already inspecs/
Summary | 35 files | 1 commit | base: main ← fix/zod-discriminated-union-multi-value
External-contract claims hold
Before: Multi-value mapping fell back to
z.union(...)everywhere, losing discriminator-aware narrowing.
After:z.discriminatedUnion(...)is emitted for all three dialects, with per-dialect strategies chosen to match each runtime'sgetDiscriminatorrules.
Verified against current colinhacks/zod source: v3's getDiscriminator only accepts ZodLiteral and ZodEnum, so the v3 expansion-into-N-branches workaround is necessary. v4's $ZodDiscriminatedUnion derives per-branch values via option._zod.propValues[discriminator], which flattens through ZodUnion of literals — so a single extend({code: z.union([z.literal(1), z.literal(2)])}) branch works on v4 native. v4-mini delegates to the same core.$ZodDiscriminatedUnion.init, so mini inherits the v4-native rules and the shared emission path is correct.
discriminated-union.ts · v3/toAst/union.ts · v4/toAst/union.ts · mini/toAst/union.ts
Defensive guards on the new predicate
Before:
tryBuildDiscriminatedUniononly handleddiscriminatorProp.const.
After: It additionally acceptslogicalOperator: 'or'with every item carryingconst, then collects the values into an array.
The added comment at lines 52–56 explicitly intends the new branch to be defensive against future IR changes. Two minor refinements (inline) close the residual edge cases — vacuous every() truth on empty items, and a one-element value array preferring z.literal over a single-element z.enum/z.union. Both are unreachable today because addItemsToSchema collapses single-item or and the parser only emits or when valueSchemas.length > 1, but tightening keeps the invariant local to the function the comment lives on.
Test matrix symmetry
Before: No coverage for multi-value discriminator mappings in the zod plugin.
After: New scenariodiscriminator-mapped-many-numberadded to all four matrix cells (v3 / v4 × 3.0.x / 3.1.x), and the previously unuseddiscriminator-mapped-many.yaml(string variant) is wired up across the same four cells. Twenty-four new snapshots cover all three dialects per scenario.
Worth noting: the zod test suite is purely snapshot-based — no parseAsync/safeParse runtime assertions in the test files. A snapshot that compiles but fails at zod construction time (e.g. duplicate discriminator value across branches, or a malformed enum) would still pass. The current snapshots look correct, but a follow-up that imports the generated module under each dialect and instantiates the schema would catch any future regression in the AST-emission paths much earlier.
zod/v3/test/3.1.x.test.ts · zod/v4/test/3.1.x.test.ts
Claude Opus | 𝕏
There was a problem hiding this comment.
Note
No actionable issues. The fix correctly extends both the IR value-extraction and per-dialect AST emission to keep z.discriminatedUnion reachable when an OpenAPI discriminator maps multiple values to the same schema, and the snapshots show that the emitted code is runtime-valid against each zod target.
TL;DR — Restores z.discriminatedUnion for OpenAPI discriminators with many-to-one mappings. The previous code only read .const from the discriminator property and fell back to a plain z.union whenever the IR encoded multi-value mappings as { logicalOperator: 'or', items: [{ const: … }, …] }.
Key changes
- Extract multi-value discriminators in the shared builder —
tryBuildDiscriminatedUnionnow also handles theor-of-constIR shape, so the discriminated path stays reachable for many-to-one mappings. - New
buildDiscriminatorExpressionhelper — choosesz.literal/z.enum/z.union(z.literal,…)per value shape and is shared by all three dialects. - Per-dialect AST emission —
miniandv4use the helper directly;v3expands non-string multi-value branches into one branch per literal becausegetDiscriminatorin zod v3 only recognisesZodLiteralandZodEnum. - Test matrix and snapshots — adds
discriminator-mapped-manyanddiscriminator-mapped-many-numberscenarios across all fourzod/v{3,4}/test/3.{0,1}.x.test.tscells, with fullmini/v3/v4snapshot coverage and two new-numberspec fixtures.
Summary | 35 files | 2 commits | base: main ← fix/zod-discriminated-union-multi-value
Multi-value discriminator extraction
Before:
tryBuildDiscriminatedUnionread onlydiscriminatorProp.const, so any{ logicalOperator: 'or', items: [{ const: 'one' }, { const: 'two' }] }shape returnednulland degraded the output to a plainz.union.
After: the same function additionally accepts anor-of-const, collects everyitem.constinto an array, and feeds that into the newbuildDiscriminatorExpressionhelper.
The guards (logicalOperator === 'or', non-empty items, and every(item => item.const !== undefined)) match the IR producers in packages/shared/src/openApi/3.{0,1}.x/parser/schema.ts — single-value mappings still flow through the const branch, and a future IR change that violated either invariant would safely fall through to the existing z.union path rather than crash.
Per-dialect emission strategy
Before: every dialect emitted
z.literal(member.discriminatedValue), which is incorrect for an array-shaped value.
After:miniandv4route throughbuildDiscriminatorExpression;v3additionally expands non-string multi-value members into one branch per literal so every option resolves to aZodLiteralthat v3'sgetDiscriminatorcan read.
Why does v3 expand and v4 collapse?
In zod v3,
getDiscriminatoronly whitelistsZodLiteralandZodEnum— aZodUnionof literals is not recognised, so the multi-value member must be split into one option per literal. In zod v4 (andzod/mini, which shares the v4 core),$ZodDiscriminatedUnionderives per-branch values viaoption._zod.values, and$ZodUnionlazily flattens its options' values, soz.union([z.literal(1), z.literal(2)])is accepted as a single branch. All-string multi-value members can stay as a singlez.enum([...])branch in every dialect.
v3/toAst/union.ts · v4/toAst/union.ts · mini/toAst/union.ts
Test matrix
Before: zod test scenarios did not cover many-to-one discriminator mappings, so the regression went unnoticed.
After: bothdiscriminator-mapped-many(string values, reusing the pre-existingspecs/3.{0,1}.x/discriminator-mapped-many.yaml) anddiscriminator-mapped-many-number(new spec fixtures, integer values) are added to all fourzod/v{3,4}/test/3.{0,1}.x.test.tscells, with the fullmini/v3/v4dialect snapshot coverage — 24 newzod.gen.tssnapshots in total.
Every snapshot emits z.discriminatedUnion; none falls back to z.union. The asymmetric output between dialects (v3 expands integer multi-value branches; v4/mini collapse to z.union(z.literal,…)) is intentional and matches each runtime's getDiscriminator contract.
zod/v3/test/3.1.x.test.ts · zod/v4/test/3.1.x.test.ts · specs/3.1.x/discriminator-mapped-many-number.yaml · .changeset/fix-zod-discriminated-union-multi-value.md
Claude Opus | 𝕏
640eb2a to
1562cef
Compare
1562cef to
9a5894e
Compare
|
Hi @mrlubos, would you be able to take a look at this PR? Hopefully pullfrog has done a good job of initial review rounds. Apologies for not starting with a discussion or Issue. I can do so if you require. 🙇 |

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