Skip to content

feat(zod,valibot): generate discriminated unions for oneOf/anyOf with discriminator mapping#3780

Merged
mrlubos merged 7 commits intomainfrom
copilot/generate-discriminated-union-types
Apr 17, 2026
Merged

feat(zod,valibot): generate discriminated unions for oneOf/anyOf with discriminator mapping#3780
mrlubos merged 7 commits intomainfrom
copilot/generate-discriminated-union-types

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 16, 2026

When an OpenAPI schema uses oneOf/anyOf with a discriminator + mapping, the Zod plugin was emitting z.union([z.object({...}).and(zRef), ...]) and the Valibot plugin was emitting v.union([...]) — both losing type-narrowing and runtime validation optimisation.

Changes

  • IR (packages/shared/src/ir/types.ts): Added discriminator?: { propertyName: string } to IRSchemaObject as the single source of truth for discriminated unions.
  • Parsers (3.0.x & 3.1.x parseAnyOf/parseOneOf): Set irSchema.discriminator when the OpenAPI schema carries a discriminator and the resulting IR schema is a union.
  • Zod constants: Added discriminatedUnion and extend identifiers.
  • Shared helper (zod/shared/discriminated-union.ts): tryBuildDiscriminatedUnion() reads parentSchema.discriminator?.propertyName from the IR and emits z.discriminatedUnion('key', [zRef.extend({ key: z.literal(val) }), ...]). Returns null (falls back to z.union()) when no discriminator is present or the pattern cannot be applied (lazy refs, inline schemas, etc.). .extend() is required because z.discriminatedUnion() requires ZodObject members — .and() produces a ZodIntersection which Zod rejects.
  • Zod v3 & v4 walkers: union() attempts tryBuildDiscriminatedUnion() before falling back to z.union(). Zod mini is unchanged — .extend() is absent in that package.
  • Valibot v1/toAst/union.ts: Reads parentSchema.discriminator?.propertyName from the IR and emits v.variant('key', [...intersectItems]). Valibot's variant accepts intersect schemas directly so no .extend() equivalent is needed.

Example

Zod:

// Before
export const zFooUnion = z.union([
    z.object({ id: z.literal('bar') }).and(zBarUnion),
    z.object({ id: z.literal('baz') }).and(zBazUnion)
]);

// After
export const zFooUnion = z.discriminatedUnion('id', [
    zBarUnion.extend({ id: z.literal('bar') }),
    zBazUnion.extend({ id: z.literal('baz') })
]);

Valibot:

// Before
export const vFooUnion = v.union([
    v.intersect([v.object({ id: v.literal('bar') }), vBarUnion]),
    v.intersect([v.object({ id: v.literal('baz') }), vBazUnion])
]);

// After
export const vFooUnion = v.variant('id', [
    v.intersect([v.object({ id: v.literal('bar') }), vBarUnion]),
    v.intersect([v.object({ id: v.literal('baz') }), vBazUnion])
]);

Applies to Zod v3, Zod v4, and Valibot v1. Zod mini continues to emit z.union() as .extend() is not available in that package. The @hey-api/typescript plugin required no changes — TypeScript handles discriminated unions natively through structural typing.

@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 Apr 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hey-api-docs Ready Ready Preview, Comment Apr 17, 2026 5:36pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 16, 2026

🦋 Changeset detected

Latest commit: 86c0795

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

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog bot commented Apr 16, 2026

TL;DR — When an OpenAPI schema uses oneOf/anyOf with a discriminator mapping, the Zod plugins now generate z.discriminatedUnion() (using .extend() in v3/v4 and z.extend() in mini to narrow the discriminator literal on each variant), and the Valibot plugin conditionally generates v.variant() when the branch structure permits. This replaces the previous z.union() / v.union() output for discriminated schemas, enabling native discriminated-union validation. Closes #3270.

Key changes

  • Propagate discriminator from OpenAPI into the IR — The 3.0.x and 3.1.x parseOneOf/parseAnyOf parsers now copy discriminator.propertyName onto IRSchemaObject when the logical operator is 'or'
  • New shared tryBuildDiscriminatedUnion helper for Zod — Inspects union members for an allOf pattern (discriminator literal + $ref), resolves the referenced schema, and emits z.discriminatedUnion('key', [ref.extend({ key: z.literal(value) }), ...]); returns null (fallback to z.union()) when the structure does not fit
  • New hasIntersectionDiscriminatorBranches helper for Valibot — Detects when union branches use allOf intersections with discriminator literals; when absent, the Valibot union builder emits v.variant(key, [...]) instead of v.union([...])
  • Extract unionToAst / intersectionToAst into dedicated files for all three Zod sub-plugins — Moves union and intersection logic out of walker.ts into toAst/union.ts and toAst/intersection.ts for mini, v3, and v4, replacing walk/walkerCtx on resolver contexts with pre-walked childResults
  • New discriminatedUnion and extend identifiers in Zod constants — Added to the shared identifier map so the DSL can reference them
  • Three new test scenarios per plugindiscriminator-all-of, discriminator-any-of, and discriminator-one-of specs with snapshots across all Zod flavors (mini, v3, v4) and Valibot; existing default snapshots updated to reflect the new output

Summary | 55 files | 7 commits | base: maincopilot/generate-discriminated-union-types


Discriminator propagation through the IR

Before: The IR SchemaObject had no discriminator metadata; plugins had to structurally detect allOf patterns to guess whether a union was discriminated.
After: parseOneOf and parseAnyOf in both 3.0.x and 3.1.x parsers now set irSchema.discriminator = { propertyName } when the source schema has a discriminator object and the IR operator is 'or'.

A new optional discriminator field on IRSchemaObject carries the property name downstream, so plugin code can check parentSchema.discriminator?.propertyName directly rather than re-inspecting child schema shapes.

packages/shared/src/ir/types.ts · packages/shared/src/openApi/3.1.x/parser/schema.ts · packages/shared/src/openApi/3.0.x/parser/schema.ts


Zod z.discriminatedUnion() generation

Before: All oneOf/anyOf unions emitted z.union([z.intersection(z.object({ kind: z.literal('x') }), zRef), ...]).
After: When a discriminator mapping is present, the output is z.discriminatedUnion('kind', [zRef.extend({ kind: z.literal('x') }), ...]) (v3/v4) or z.discriminatedUnion('kind', [z.extend(zRef, { kind: z.literal('x') }), ...]) (mini).

The core logic lives in the shared tryBuildDiscriminatedUnion helper. It validates that each union member is an allOf of exactly two items (a discriminator-literal object and a $ref), that no lazy references are involved, and that the discriminator value is a known constant. If any member fails these checks, it returns null and the caller falls back to z.union().

All three Zod sub-plugins (mini, v3, v4) now consume this helper. Mini uses z.extend(ref, { ... }) (free function) while v3 and v4 use ref.extend({ ... }) (method call), matching each package's API surface.

Why .extend() instead of inlining the full object? Zod's discriminatedUnion requires each variant to be a ZodObject. Using ref.extend({ key: z.literal(value) }) merges the discriminator literal into the existing referenced schema, satisfying this constraint while keeping the output compact and preserving the reference relationship.

packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts · packages/openapi-ts/src/plugins/zod/mini/toAst/union.ts · packages/openapi-ts/src/plugins/zod/v3/toAst/union.ts · packages/openapi-ts/src/plugins/zod/v4/toAst/union.ts


Valibot v.variant() support

Before: All unions emitted v.union([...]).
After: When parentSchema.discriminator.propertyName is set and no branches use the allOf[discriminatorLiteral, $ref] intersection pattern, the output is v.variant('key', [...]).

The new hasIntersectionDiscriminatorBranches helper detects whether union members follow the intersection-based discriminator pattern. When they do (the typical case for mapped discriminators), Valibot falls back to v.union() because v.variant() requires the discriminator to be directly accessible on each branch — it cannot look inside v.intersect() wrappers. The v.variant() path is reserved for schemas where the discriminator key is already a top-level property of each variant.

packages/openapi-ts/src/plugins/valibot/shared/discriminated-union.ts · packages/openapi-ts/src/plugins/valibot/v1/toAst/union.ts


Refactor: extract toAst helpers from walkers

Before: Union and intersection AST construction was inlined in each Zod walker's visitor callbacks, tightly coupled to walk/walkerCtx.
After: Each sub-plugin (mini, v3, v4) has standalone toAst/union.ts and toAst/intersection.ts modules; the walker just delegates.

This structural refactor made the discriminated union feature possible. By decoupling AST construction from the walker, the new tryBuildDiscriminatedUnion shared helper can operate on pre-walked results without needing access to the walker internals. The IntersectionResolverContext and UnionResolverContext types were updated to replace walk/walkerCtx with schemas: ReadonlyArray<IR.SchemaObject> and pre-walked childResults.

packages/openapi-ts/src/plugins/zod/mini/walker.ts · packages/openapi-ts/src/plugins/zod/v3/walker.ts · packages/openapi-ts/src/plugins/zod/v4/walker.ts · packages/openapi-ts/src/plugins/zod/resolvers.ts

Pullfrog  | View workflow run | via Pullfrog𝕏

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.

This PR contains no code changes — the single "Initial plan" commit is empty (0 files, 0 additions, 0 deletions). There is nothing to review yet.

Once implementation begins, key areas to watch:

  • How discriminator.mapping is resolved during IR construction (especially for $ref values vs. inline schemas)
  • Whether the changes correctly handle oneOf + discriminator vs. allOf + discriminator patterns, which have different semantics
  • Impact on existing snapshot tests
  • Zod plugin changes to emit z.discriminatedUnion() instead of .and() chains

Pullfrog  | Fix it ➔View workflow run𝕏

Copilot AI changed the title [WIP] Add discriminated union types generation from OpenAPI discriminators feat(zod): generate z.discriminatedUnion() for oneOf/anyOf with discriminator mapping Apr 16, 2026
Copilot AI requested a review from mrlubos April 16, 2026 09:18
@mrlubos
Copy link
Copy Markdown
Member

mrlubos commented Apr 16, 2026

@copilot is .extend() necessary to make discriminated unions work? Wasn't .and() enough?

The @hey-api/typescript plugin previously supported discriminators without modifying IR. How come? Should we refactor something there and perhaps simplify, or is changing IR not needed to add support for discriminated unions to Zod?

Lastly, add the same support to Valibot using .variant API

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 16, 2026

@copilot is .extend() necessary to make discriminated unions work? Wasn't .and() enough?

The @hey-api/typescript plugin previously supported discriminators without modifying IR. How come? Shoul...

Yes, .extend() is necessary for Zod. z.discriminatedUnion() requires each member to be a ZodObject. Using .and() produces a ZodIntersection which Zod rejects — .extend() merges the literal override into a new ZodObject so Zod accepts it.

For the IR: I've reverted the IR discriminator field entirely and the parser changes. Like the TypeScript plugin, the Zod and Valibot code now derive the discriminator key purely by inspecting the IR structure (all non-null union items are and intersections whose first part is an object with exactly one const-valued property — that key is the discriminator). No IR change needed.

Valibot v.variant() support added in commit ea356ea. The existing union-member expressions are already v.intersect([discriminatorObj, vRef]) schemas, which Valibot's variant accepts directly — no .extend() equivalent needed.

Copilot AI changed the title feat(zod): generate z.discriminatedUnion() for oneOf/anyOf with discriminator mapping feat(zod,valibot): generate discriminated unions for oneOf/anyOf with discriminator mapping Apr 16, 2026
@mrlubos
Copy link
Copy Markdown
Member

mrlubos commented Apr 16, 2026

@copilot add discriminator to IR again. Clean up Zod, Valibot, and TypeScript plugins to not detect their own discriminators and use the IR instead.

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 16, 2026

@copilot add discriminator to IR again. Clean up Zod, Valibot, and TypeScript plugins to not detect their own discriminators and use the IR instead.

Done in commit 6cd823c.

  • Added discriminator?: { propertyName: string } to IRSchemaObject.
  • 3.0.x and 3.1.x parsers now set irSchema.discriminator at the end of parseAnyOf/parseOneOf when schema.discriminator is present and the result is a union schema.
  • Zod tryBuildDiscriminatedUnion now reads parentSchema.discriminator?.propertyName from the IR — the detectDiscriminatorKey structural detection function is removed.
  • Valibot union.ts baseNode does the same — reads parentSchema.discriminator?.propertyName and emits v.variant() accordingly.
  • TypeScript plugin required no changes — it doesn't detect discriminators at all (TypeScript handles them natively through structural typing).

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 17, 2026

Codecov Report

❌ Patch coverage is 0% with 184 lines in your changes missing coverage. Please review.
✅ Project coverage is 39.75%. Comparing base (1a33cb2) to head (86c0795).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
...kages/openapi-ts/src/plugins/zod/v3/toAst/union.ts 0.00% 25 Missing and 6 partials ⚠️
...ges/openapi-ts/src/plugins/zod/mini/toAst/union.ts 0.00% 24 Missing and 6 partials ⚠️
...penapi-ts/src/plugins/zod/v3/toAst/intersection.ts 0.00% 24 Missing and 3 partials ⚠️
...napi-ts/src/plugins/zod/mini/toAst/intersection.ts 0.00% 19 Missing and 2 partials ⚠️
...i-ts/src/plugins/zod/shared/discriminated-union.ts 0.00% 14 Missing and 7 partials ⚠️
.../src/plugins/valibot/shared/discriminated-union.ts 0.00% 9 Missing and 4 partials ⚠️
...penapi-ts/src/plugins/zod/v4/toAst/intersection.ts 0.00% 12 Missing and 1 partial ⚠️
...kages/openapi-ts/src/plugins/zod/v4/toAst/union.ts 0.00% 8 Missing and 1 partial ⚠️
...s/openapi-ts/src/plugins/valibot/v1/toAst/union.ts 0.00% 4 Missing and 1 partial ⚠️
packages/shared/src/openApi/3.0.x/parser/schema.ts 0.00% 2 Missing and 2 partials ⚠️
... and 4 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3780      +/-   ##
==========================================
- Coverage   40.07%   39.75%   -0.32%     
==========================================
  Files         522      530       +8     
  Lines       19311    19462     +151     
  Branches     5766     5789      +23     
==========================================
  Hits         7738     7738              
- Misses       9371     9491     +120     
- Partials     2202     2233      +31     
Flag Coverage Δ
unittests 39.75% <0.00%> (-0.32%) ⬇️

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.

@mrlubos mrlubos marked this pull request as ready for review April 17, 2026 06:58
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog bot commented Apr 17, 2026

Reviewed PR #3780. Submitted a review with 6 inline comments. Key finding: the PR description incorrectly claims Zod mini does not support .extend() / z.discriminatedUnion(), but both are available and the generated code is correct. The refactoring is clean; flagged a minor correctness concern about the v4 empty-intersection edge case and a test description mismatch.

Task list (9/9 completed)
  • Read PR description and diff overview
  • Review core logic changes (shared IR types, OpenAPI parsers)
  • Review Zod plugin changes (shared discriminated-union, mini/v3/v4 walkers and toAst)
  • Review Valibot plugin changes
  • Review test specs and snapshots
  • Review changesets
  • Deep dive: v4 intersection behavior change
  • Deep dive: mini discriminatedUnion + extend correctness
  • Submit review

Pullfrog  | View workflow run | via Pullfrog𝕏

@dosubot dosubot bot added size:XL This PR changes 500-999 lines, ignoring generated files. feature 🚀 Feature request. labels Apr 17, 2026
@dosubot dosubot bot added the lgtm This PR has been approved by a maintainer label Apr 17, 2026
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

The PR description claims "Zod mini continues to emit z.union() as .extend() is not available in that package," but the mini snapshots and code both show z.discriminatedUnion() with .extend() being generated. Both APIs are available in zod/v4-mini, so the generated code is correct at runtime — but the description is misleading and should be updated. The refactoring from inlined walker logic into shared intersectionToAst/unionToAst files is clean. One correctness issue below with the tryBuildDiscriminatedUnion null-skipping logic.

Task list (9/9 completed)
  • Read PR description and diff overview
  • Review core logic changes (shared IR types, OpenAPI parsers)
  • Review Zod plugin changes (shared discriminated-union, mini/v3/v4 walkers and toAst)
  • Review Valibot plugin changes
  • Review test specs and snapshots
  • Review changesets
  • Deep dive: v4 intersection behavior change
  • Deep dive: mini discriminatedUnion + extend correctness
  • Submit review

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

Comment thread packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts
Comment thread packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts
Comment thread packages/openapi-ts/src/plugins/zod/mini/toAst/union.ts
Comment thread packages/openapi-ts/src/plugins/zod/v4/toAst/intersection.ts Outdated
Comment thread packages/openapi-ts/src/plugins/valibot/v1/toAst/union.ts
Comment thread packages/openapi-ts-tests/zod/v3/test/3.1.x.test.ts
@mrlubos mrlubos force-pushed the copilot/generate-discriminated-union-types branch from ceead13 to 64c1d5e Compare April 17, 2026 08:57
@mrlubos mrlubos force-pushed the copilot/generate-discriminated-union-types branch from 59f1535 to 86c0795 Compare April 17, 2026 17:35
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 17, 2026

Open in StackBlitz

@hey-api/codegen-core

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

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

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

@hey-api/nuxt

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

@hey-api/openapi-ts

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

@hey-api/shared

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

@hey-api/spec-types

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

@hey-api/types

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

@hey-api/vite-plugin

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

commit: 86c0795

@mrlubos mrlubos merged commit 7a1ad87 into main Apr 17, 2026
10 of 12 checks passed
@mrlubos mrlubos deleted the copilot/generate-discriminated-union-types branch April 17, 2026 17:40
@hey-api hey-api bot mentioned this pull request Apr 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature 🚀 Feature request. lgtm This PR has been approved by a maintainer size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generate discriminated union types from OpenAPI discriminators

2 participants