Skip to content

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

Draft
CallumJHays wants to merge 10 commits into
hey-api:mainfrom
CallumJHays:fix/zod-discriminated-union-multi-value
Draft

fix(zod): generate z.discriminatedUnion for multi-value discriminator mappings#3857
CallumJHays wants to merge 10 commits 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

… 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
@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: 49dacb0

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 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 39.54%. Comparing base (1721aa6) to head (49dacb0).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
...i-ts/src/plugins/zod/shared/discriminated-union.ts 0.00% 9 Missing and 4 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.04%     
==========================================
  Files         532      532              
  Lines       19581    19598      +17     
  Branches     5835     5837       +2     
==========================================
  Hits         7751     7751              
- Misses       9582     9593      +11     
- Partials     2248     2254       +6     
Flag Coverage Δ
unittests 39.54% <0.00%> (-0.04%) ⬇️

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: 49dacb0

Closes the only missing cell (v3 × 3.0.x) flagged by the PR reviewer.
@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
… 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
@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
…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.
@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 and others added 2 commits May 12, 2026 10:19
@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
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