Skip to content

fix(zod): generate z.discriminatedUnion for multi-value discriminator mappings#3857

Open
CallumJHays wants to merge 1 commit into
hey-api:mainfrom
CallumJHays:fix/zod-discriminated-union-multi-value
Open

fix(zod): generate z.discriminatedUnion for multi-value discriminator mappings#3857
CallumJHays wants to merge 1 commit into
hey-api:mainfrom
CallumJHays:fix/zod-discriminated-union-multi-value

Conversation

@CallumJHays
Copy link
Copy Markdown

@CallumJHays CallumJHays commented May 8, 2026

Summary

  • Bug: When an OpenAPI discriminator maps multiple values to the same schema (e.g. mapping: { one: '#/components/schemas/Bar', two: '#/components/schemas/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 IR stores multiple values, it uses { logicalOperator: 'or', items: [{ const: 'one' }, { const: 'two' }] } — a pattern the function didn't handle, causing it to return null and fall back to z.union.
  • Fix: Extended value extraction in discriminated-union.ts to detect the logicalOperator: 'or' pattern and collect all const values into an array. Extended union AST builders in v3, v4, and mini to emit z.enum([...]) for multi-value members and z.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 properties
  • packages/openapi-ts/src/plugins/zod/v3/toAst/union.ts — emit z.enum vs z.literal based on value count
  • packages/openapi-ts/src/plugins/zod/v4/toAst/union.ts — same
  • packages/openapi-ts/src/plugins/zod/mini/toAst/union.ts — same
  • Added discriminator-mapped-many test scenario to zod v3 and v4 suites (OpenAPI 3.0.x and 3.1.x)
  • New snapshots showing correct z.discriminatedUnion output with mixed z.enum/z.literal members

Example

Input (discriminator-mapped-many.yaml):

openapi: 3.1.0
components:
  schemas:
    Foo:
      oneOf:
        - $ref: '#/components/schemas/Bar'
        - $ref: '#/components/schemas/Baz'
        - $ref: '#/components/schemas/Spæcial'
      discriminator:
        propertyName: foo
        mapping:
          one: '#/components/schemas/Bar'
          two: '#/components/schemas/Bar'   # two values map to the same schema
          three: '#/components/schemas/Baz'
          four: '#/components/schemas/Spæcial'
    Bar:
      type: object
      properties:
        foo:
          type: string
          enum: [one, two]
    Baz:
      type: object
      properties:
        foo:
          type: string
          enum: [three]
    Spæcial:
      type: object
      properties:
        foo:
          type: string
          enum: [four]

Before (incorrect — discriminator-aware narrowing lost, multi-value member expanded inline):

export const zFoo = z.union([
    z.object({
        foo: z.union([
            z.literal('one'),
            z.literal('two')
        ])
    }).and(zBar),
    z.object({
        foo: z.literal('three')
    }).and(zBaz),
    z.object({
        foo: z.literal('four')
    }).and(zSpæcial)
]);

After (correct — discriminator-aware narrowing preserved):

export const zFoo = z.discriminatedUnion('foo', [
    zBar.extend({ foo: z.enum(['one', 'two']) }),
    zBaz.extend({ foo: z.literal('three') }),
    zSpæcial.extend({ foo: z.literal('four') })
]);

Test plan

  • pnpm build --filter="@hey-api/**" passes
  • All zod v3 and v4 snapshot tests pass (updated snapshots match expected output)
  • Test scenarios added for all matrix cells: zod v3 + v4 × OpenAPI 3.0.x + 3.1.x
  • Changeset added as patch for @hey-api/openapi-ts

@bolt-new-by-stackblitz
Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 8, 2026

@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-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 8, 2026

🦋 Changeset detected

Latest commit: 9a5894e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@hey-api/openapi-ts Patch

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

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. bug 🔥 Broken or incorrect behavior. labels May 8, 2026
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented May 8, 2026

TL;DR — Fixes the zod plugin so that discriminated unions with multi-value discriminator mappings (multiple mapping keys pointing at the same schema) emit z.discriminatedUnion with z.enum([...]) members instead of silently falling back to z.union.

Key changes

  • Detect multi-value discriminator propertiestryBuildDiscriminatedUnion now recognises the IR's logicalOperator: 'or' pattern in addition to plain const, collecting every mapped value instead of returning null.
  • Emit z.enum for array-valued discriminatorsv3, v4, and mini union AST builders branch on Array.isArray(member.discriminatedValue) to pick between z.enum([...]) and z.literal(...).
  • New discriminator-mapped-many fixtures — added scenarios in zod v3 and v4 suites with snapshots across mini, v3, and v4 outputs for OpenAPI 3.0.x and 3.1.x, locking in the corrected shape.

Summary | 17 files | 1 commit | base: mainfix/zod-discriminated-union-multi-value


Multi-value discriminator mappings now take the discriminated path

Before: mapping: { one: '#/Bar', two: '#/Bar' } silently degraded to z.union([zBar, zBaz, zSpæcial]), losing the discriminator-aware narrowing that zod provides.
After: the plugin emits z.discriminatedUnion('foo', [...]) with each member extended by z.enum([...]) when it has multiple mapped values, or z.literal(...) when it has exactly one.

The fallback was triggered by a single line — schema.items[0]!.properties?.[discriminatorKey]?.const — that only understood single-value discriminator properties. The IR represents multiple mapped values as { logicalOperator: 'or', items: [{ const: 'one' }, { const: 'two' }] }, which bypassed the const read and made the whole discriminated union builder return null. The fix keeps the single-value fast path and adds a second branch that maps the or items to an array of consts, propagated through the builders as member.discriminatedValue.

Why both `z.enum` and `z.literal`? Zod's `discriminatedUnion` requires each member to narrow the discriminator to a known set. `z.literal('x')` narrows to one value; `z.enum(['x', 'y'])` narrows to a finite set. Picking per-member keeps output minimal — a mapping with a single value still renders as `z.literal(...)` rather than a one-element enum.

discriminated-union.ts · v3/toAst/union.ts · v4/toAst/union.ts · mini/toAst/union.ts

Pullfrog  | View workflow run | via Pullfrog𝕏

@CallumJHays
Copy link
Copy Markdown
Author

Sorry about this. Claude ran a little wild, will re-raise after more careful review.

@CallumJHays CallumJHays closed this May 8, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 8, 2026

Codecov Report

❌ Patch coverage is 0% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 39.54%. Comparing base (66720d9) to head (9a5894e).

Files with missing lines Patch % Lines
...i-ts/src/plugins/zod/shared/discriminated-union.ts 0.00% 11 Missing and 5 partials ⚠️
...kages/openapi-ts/src/plugins/zod/v3/toAst/union.ts 0.00% 5 Missing and 2 partials ⚠️
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     
Flag Coverage Δ
unittests 39.54% <0.00%> (-0.05%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run𝕏

Comment thread packages/openapi-ts-tests/zod/v3/test/3.1.x.test.ts
@CallumJHays CallumJHays reopened this May 11, 2026
@CallumJHays CallumJHays marked this pull request as draft May 11, 2026 00:21
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 11, 2026

Open in StackBlitz

@hey-api/codegen-core

npm i https://pkg.pr.new/@hey-api/codegen-core@3857

@hey-api/json-schema-ref-parser

npm i https://pkg.pr.new/@hey-api/json-schema-ref-parser@3857

@hey-api/nuxt

npm i https://pkg.pr.new/@hey-api/nuxt@3857

@hey-api/openapi-ts

npm i https://pkg.pr.new/@hey-api/openapi-ts@3857

@hey-api/shared

npm i https://pkg.pr.new/@hey-api/shared@3857

@hey-api/spec-types

npm i https://pkg.pr.new/@hey-api/spec-types@3857

@hey-api/types

npm i https://pkg.pr.new/@hey-api/types@3857

@hey-api/vite-plugin

npm i https://pkg.pr.new/@hey-api/vite-plugin@3857

commit: 9a5894e

@CallumJHays CallumJHays force-pushed the fix/zod-discriminated-union-multi-value branch from 96915a5 to f327fb2 Compare May 11, 2026 04:58
@CallumJHays CallumJHays marked this pull request as ready for review May 11, 2026 04:58
@CallumJHays CallumJHays force-pushed the fix/zod-discriminated-union-multi-value branch from f327fb2 to aa5a2e3 Compare May 11, 2026 05:02
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 or discriminator schemas in the shared buildertryBuildDiscriminatedUnion now collects every const from a logicalOperator: 'or' group into an array, in addition to the single-const case.
  • Branch on array vs scalar in each dialect emitterv3, v4, and mini union.ts emit z.enum(values) when discriminatedValue is an array and z.literal(value) otherwise.
  • Add discriminator-mapped-many scenario across the test matrix — present in all four cells (zod v3/v4 × OpenAPI 3.0.x/3.1.x), with snapshots for the v3, v4, and mini dialects.

Summary | 21 files | 3 commits | base: mainfix/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: emits z.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.ts had no discriminator scenarios and the new bug had no fixture in any of the four cells.
After: discriminator-mapped-many is exercised in all four cells, each with v3/v4/mini snapshots (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

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts
@CallumJHays CallumJHays marked this pull request as draft May 11, 2026 05:04
@CallumJHays CallumJHays marked this pull request as ready for review May 11, 2026 07:42
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels May 11, 2026
@CallumJHays CallumJHays marked this pull request as draft May 11, 2026 07:42
@CallumJHays CallumJHays marked this pull request as ready for review May 11, 2026 07:50
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 IRtryBuildDiscriminatedUnion now 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 picks z.literal(x) for scalars, z.enum([...]) for all-string arrays, and z.union([z.literal(...), ...]) for mixed-type arrays.
  • v3 non-string expansion — zod v3's getDiscriminator does not accept ZodUnion, so v3/toAst/union.ts expands non-string multi-value members into one branch per literal.
  • Test matrix symmetry — adds discriminator-mapped-many and discriminator-mapped-many-number scenarios to all four zod/v{3,4}/test/3.{0,1}.x.test.ts cells, closing the previously-sparse zod/v3/3.0.x cell, with 24 new snapshots across the three dialect subdirs.

Summary | 35 files | 8 commits | base: mainfix/zod-discriminated-union-multi-value


IR pattern detection in tryBuildDiscriminatedUnion

Before: Read only discriminatorProp.const, returned null for any multi-value mapping → fell back to plain z.union and lost discriminator-aware narrowing.
After: Also matches logicalOperator: 'or' of all-const items, collecting values into an array passed downstream as discriminatedValue.

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.

shared/discriminated-union.ts


Per-dialect emission strategies

Before: All three dialects emitted z.literal(member.discriminatedValue) unconditionally — wrong for arrays.
After: mini and v4 rely on buildDiscriminatorExpression; v3 additionally expands non-string arrays into one branch per literal because ZodUnion is 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 $ZodDiscriminatedUnion derives per-branch values via option._zod.values, and z.union([z.literal(1), z.literal(2)]) exposes {1, 2} through flatMap of its options' value sets — so a ZodUnion of literals is a valid discriminator branch in v4 and v4-mini. v3's getDiscriminator predates this and only whitelists ZodLiteral/ZodEnum/ZodNativeEnum plus 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-many was missing from zod/v3/test/3.0.x.test.ts (the previously-sparse cell); no -number variant existed anywhere.
After: Both scenarios present in all four test cells with byte-identical descriptions; 24 new snapshots cover the full 2 scenarios × 4 cells × 3 dialects matrix.

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

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/openapi-ts/src/plugins/zod/v4/toAst/union.ts Outdated
@CallumJHays CallumJHays marked this pull request as draft May 12, 2026 00:18
@CallumJHays CallumJHays marked this pull request as ready for review May 12, 2026 00:53
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 tryBuildDiscriminatedUnion to recognise { logicalOperator: 'or', items: [{ const: ... }, ...] } in addition to single-const properties.
  • Add buildDiscriminatorExpression helper — emits z.literal(v) for scalars, z.enum([...]) for all-string arrays, and z.union([z.literal(...), ...]) for non-string arrays.
  • v3 dialect splits non-string multi-value into one branch per literal — required because zod v3's getDiscriminator only accepts ZodLiteral / ZodEnum / ZodNativeEnum, not ZodUnion.
  • v4 and mini emit z.union of literals directly$ZodDiscriminatedUnion flattens through option._zod.values so ZodUnion of literals is a valid branch.
  • Test matrix expanded symmetrically — both discriminator-mapped-many (all-strings) and discriminator-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: mainfix/zod-discriminated-union-multi-value


Per-dialect branch shapes

Before: z.union([...]) fallback whenever mapping had 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 buildDiscriminatorExpression is 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, but findDiscriminatorPropertyType resolves a single propertyType per discriminator before convertDiscriminatorValue runs, so all entries in the resulting or-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's ZodEnum-only constraint and acknowledged by the inline comment.
  • valibot plugin was not updated. packages/openapi-ts/src/plugins/valibot/shared/discriminated-union.ts still reads only .const and falls back to union for multi-value mappings. Out of scope for this PR but a candidate follow-up if symmetry across plugins is desired.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@CallumJHays CallumJHays marked this pull request as draft May 12, 2026 01:27
@CallumJHays CallumJHays marked this pull request as ready for review May 13, 2026 02:23
@CallumJHays CallumJHays marked this pull request as draft May 13, 2026 02:31
@CallumJHays CallumJHays force-pushed the fix/zod-discriminated-union-multi-value branch from 7bcc24e to 6de0b51 Compare May 13, 2026 02:34
@CallumJHays CallumJHays marked this pull request as ready for review May 13, 2026 02:37
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in tryBuildDiscriminatedUnion — 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 separate extend({code: z.literal(1)}) / extend({code: z.literal(2)}) branches because v3 getDiscriminator does not accept ZodUnion
  • 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 in specs/

Summary | 35 files | 1 commit | base: mainfix/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's getDiscriminator rules.

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: tryBuildDiscriminatedUnion only handled discriminatorProp.const.
After: It additionally accepts logicalOperator: 'or' with every item carrying const, 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.

discriminated-union.ts


Test matrix symmetry

Before: No coverage for multi-value discriminator mappings in the zod plugin.
After: New scenario discriminator-mapped-many-number added to all four matrix cells (v3 / v4 × 3.0.x / 3.1.x), and the previously unused discriminator-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

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts
Comment thread packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts Outdated
Comment thread packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts
@CallumJHays CallumJHays marked this pull request as draft May 13, 2026 04:01
@CallumJHays CallumJHays marked this pull request as ready for review May 13, 2026 04:40
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 buildertryBuildDiscriminatedUnion now also handles the or-of-const IR shape, so the discriminated path stays reachable for many-to-one mappings.
  • New buildDiscriminatorExpression helper — chooses z.literal / z.enum / z.union(z.literal,…) per value shape and is shared by all three dialects.
  • Per-dialect AST emissionmini and v4 use the helper directly; v3 expands non-string multi-value branches into one branch per literal because getDiscriminator in zod v3 only recognises ZodLiteral and ZodEnum.
  • Test matrix and snapshots — adds discriminator-mapped-many and discriminator-mapped-many-number scenarios across all four zod/v{3,4}/test/3.{0,1}.x.test.ts cells, with full mini / v3 / v4 snapshot coverage and two new -number spec fixtures.

Summary | 35 files | 2 commits | base: mainfix/zod-discriminated-union-multi-value


Multi-value discriminator extraction

Before: tryBuildDiscriminatedUnion read only discriminatorProp.const, so any { logicalOperator: 'or', items: [{ const: 'one' }, { const: 'two' }] } shape returned null and degraded the output to a plain z.union.
After: the same function additionally accepts an or-of-const, collects every item.const into an array, and feeds that into the new buildDiscriminatorExpression helper.

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.

shared/discriminated-union.ts


Per-dialect emission strategy

Before: every dialect emitted z.literal(member.discriminatedValue), which is incorrect for an array-shaped value.
After: mini and v4 route through buildDiscriminatorExpression; v3 additionally expands non-string multi-value members into one branch per literal so every option resolves to a ZodLiteral that v3's getDiscriminator can read.

Why does v3 expand and v4 collapse?

In zod v3, getDiscriminator only whitelists ZodLiteral and ZodEnum — a ZodUnion of literals is not recognised, so the multi-value member must be split into one option per literal. In zod v4 (and zod/mini, which shares the v4 core), $ZodDiscriminatedUnion derives per-branch values via option._zod.values, and $ZodUnion lazily flattens its options' values, so z.union([z.literal(1), z.literal(2)]) is accepted as a single branch. All-string multi-value members can stay as a single z.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: both discriminator-mapped-many (string values, reusing the pre-existing specs/3.{0,1}.x/discriminator-mapped-many.yaml) and discriminator-mapped-many-number (new spec fixtures, integer values) are added to all four zod/v{3,4}/test/3.{0,1}.x.test.ts cells, with the full mini / v3 / v4 dialect snapshot coverage — 24 new zod.gen.ts snapshots 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

Pullfrog  | View workflow run | Using Claude Opus𝕏

@CallumJHays CallumJHays force-pushed the fix/zod-discriminated-union-multi-value branch from 640eb2a to 1562cef Compare May 13, 2026 05:10
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:XL This PR changes 500-999 lines, ignoring generated files. labels May 13, 2026
@CallumJHays CallumJHays force-pushed the fix/zod-discriminated-union-multi-value branch from 1562cef to 9a5894e Compare May 13, 2026 05:22
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels May 13, 2026
@CallumJHays
Copy link
Copy Markdown
Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug 🔥 Broken or incorrect behavior. size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant