Skip to content

Conversation

@Teablack
Copy link
Contributor

@Teablack Teablack commented Nov 17, 2025

Description

Fixes #4089 - discriminated unions generating anyOf instead of oneOf in JSON Schema output.

Discriminated unions should use oneOf (exactly one match) instead of anyOf (one or more matches) because the discriminator field ensures mutual exclusivity between options. This is semantically correct according to JSON Schema specifications.

Changes

  • Modified to-json-schema.ts to detect discriminated unions via the discriminator property
  • Discriminated unions now generate oneOf in JSON Schema
  • Regular unions continue to use anyOf as before
  • Added test case for discriminated unions in to-json-schema.test.ts

Testing
✅ All existing tests pass
✅ Added new test case for discriminated unions
✅ Test validates correct oneOf generation with proper schema structure

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 17, 2025

Walkthrough

The changes implement support for discriminated unions in JSON Schema generation for Zod v4. When generating schemas, discriminated unions now emit oneOf instead of anyOf when a discriminator is detected via def.discriminator. Non-discriminated unions continue using anyOf. A new test case validates this serialization behavior for discriminated unions with a discriminator field and multiple object variants, ensuring the output includes proper schema constraints and required fields.

Pre-merge checks

✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: use oneOf for discriminated unions in JSON Schema' accurately and concisely describes the main change—switching from anyOf to oneOf for discriminated unions in JSON Schema generation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description check ✅ Passed The PR description clearly describes the changeset - fixing discriminated unions to use oneOf instead of anyOf in JSON Schema, with specific details about what was modified and tested.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
packages/zod/src/v4/classic/tests/to-json-schema.test.ts (1)

660-707: Nice test coverage for the basic case!

The test validates that discriminated unions correctly generate oneOf with proper structure, including the const values for the discriminator field. The inline snapshot approach is solid for catching any regressions.

Consider adding a few more test cases to cover edge scenarios:

  • Different discriminator keys (not just "type")
  • More than 2 union variants
  • Single variant edge case
  • Different targets (draft-7, openapi-3.0) to ensure oneOf works across specs

These are nice-to-haves for more comprehensive coverage, but the current test is good for the main use case.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c3ec66c and b82907d.

📒 Files selected for processing (2)
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts (1 hunks)
  • packages/zod/src/v4/core/to-json-schema.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (12)
**/*.{js,jsx,ts,tsx,mjs,cjs,json}

📄 CodeRabbit inference engine (CLAUDE.md)

Enforce line width of 120 characters via Biome formatting

Files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
**/*.{js,jsx,ts,tsx,mjs,cjs}

📄 CodeRabbit inference engine (CLAUDE.md)

Use ES5-style trailing commas in JavaScript/TypeScript code

Files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Allow the any type in TypeScript (noExplicitAny off)
Allow non-null assertions in TypeScript (noNonNullAssertion off)
Write TypeScript to pass strict mode with exactOptionalPropertyTypes enabled
Use NodeNext module resolution semantics for imports in TypeScript
Target ES2020 language features in TypeScript source

Files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
**/*.{ts,tsx,js,jsx,mjs,cjs}

📄 CodeRabbit inference engine (CLAUDE.md)

Allow parameter reassignment for performance-sensitive code (noParameterAssign off)

Files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
**/*.{js,mjs,ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/development-setup.mdc)

**/*.{js,mjs,ts,tsx}: Use .js extensions in import specifiers (e.g., import { z } from "./index.js")
Don’t use require(); use ESM import statements

Files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
**/*.{js,mjs,cjs,jsx,ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/guidelines.mdc)

Do not leave log statements (e.g., console.log, debugger) in tests or production code

Files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
packages/**/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/zod-project-guide.mdc)

Write source code in TypeScript (TypeScript-first codebase)

Files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
packages/zod/**

📄 CodeRabbit inference engine (.cursor/rules/zod-project-guide.mdc)

Make core Zod library changes in the main package at packages/zod/

Files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
packages/zod/src/{v4/classic/tests,v4/core/tests,v3/tests}/**/*

📄 CodeRabbit inference engine (.cursor/rules/testing-guidelines.mdc)

Place all test files under packages/zod/src/v4/classic/tests, packages/zod/src/v4/core/tests, or packages/zod/src/v3/tests

Files:

  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
packages/zod/src/{v4/classic/tests,v4/core/tests,v3/tests}/**/*.test.ts

📄 CodeRabbit inference engine (.cursor/rules/testing-guidelines.mdc)

packages/zod/src/{v4/classic/tests,v4/core/tests,v3/tests}/**/*.test.ts: Test files must use the .test.ts extension (TypeScript), not JavaScript
Use import type for type-only imports in tests (e.g., import type { ... })
Permanent, regression, API validation, edge-case coverage, and performance benchmark tests must be in the test suite (not play.ts)
Use Vitest as the framework in tests and import from it as import { expect, test } from "vitest"
Import Zod in tests as import * as z from "zod/v4"
Write tests with clear, descriptive names and cover both success and failure cases
Keep test suites concise while maintaining adequate coverage
Do not skip tests due to type issues; fix the types instead
Use descriptive file names like string.test.ts, object.test.ts, url-validation.test.ts and group related functionality together

Files:

  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
packages/zod/src/v4/{classic,core}/tests/**/*.test.ts

📄 CodeRabbit inference engine (.cursor/rules/testing-workflow.mdc)

packages/zod/src/v4/{classic,core}/tests/**/*.test.ts: Use Vitest for all testing (Vitest APIs in test files)
Place tests under packages/zod/src/v4/classic/tests/ or packages/zod/src/v4/core/tests/
Name test files with the *.test.ts suffix
Use test() for individual test cases
Use describe() for test groups
Use expect() for assertions

Files:

  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
packages/**/*.test.ts

📄 CodeRabbit inference engine (.cursor/rules/zod-project-guide.mdc)

Use Vitest for tests and place test cases in .test.ts files

Files:

  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
🧠 Learnings (18)
📓 Common learnings
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/zod-internals.mdc:0-0
Timestamp: 2025-10-21T17:27:32.492Z
Learning: Applies to packages/zod/src/v4/core/schemas.ts : Ensure readonly wrapper types (e.g., $ZodReadonly) pass through values for discriminator support in unions
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/zod-internals.mdc:0-0
Timestamp: 2025-10-21T17:27:32.492Z
Learning: Applies to packages/zod/src/v4/core/schemas.ts : For $ZodDiscriminatedUnion, compute and merge propValues lazily from options; ensure each option provides the discriminator key and that values are unique
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/zod-internals.mdc:0-0
Timestamp: 2025-10-21T17:27:32.492Z
Learning: Applies to packages/zod/src/v4/core/schemas.ts : Wrapper schemas (e.g., $ZodOptional, $ZodNullable, $ZodReadonly) must pass through internal properties from their inner type using util.defineLazy for propValues, values, optin, and optout
📚 Learning: 2025-10-21T17:27:32.492Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/zod-internals.mdc:0-0
Timestamp: 2025-10-21T17:27:32.492Z
Learning: Applies to packages/zod/src/v4/core/schemas.ts : Ensure readonly wrapper types (e.g., $ZodReadonly) pass through values for discriminator support in unions

Applied to files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
📚 Learning: 2025-10-21T17:27:32.492Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/zod-internals.mdc:0-0
Timestamp: 2025-10-21T17:27:32.492Z
Learning: Applies to packages/zod/src/v4/core/schemas.ts : For $ZodDiscriminatedUnion, compute and merge propValues lazily from options; ensure each option provides the discriminator key and that values are unique

Applied to files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
📚 Learning: 2025-10-21T17:27:32.492Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/zod-internals.mdc:0-0
Timestamp: 2025-10-21T17:27:32.492Z
Learning: Applies to packages/zod/src/v4/core/schemas.ts : Wrapper schemas (e.g., $ZodOptional, $ZodNullable, $ZodReadonly) must pass through internal properties from their inner type using util.defineLazy for propValues, values, optin, and optout

Applied to files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
📚 Learning: 2025-10-21T17:27:32.492Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/zod-internals.mdc:0-0
Timestamp: 2025-10-21T17:27:32.492Z
Learning: Applies to packages/zod/src/v4/core/{schemas.ts,core.ts} : Use the custom constructor system via core.$constructor() and initialize instances with $ZodType.init() when creating schemas

Applied to files:

  • packages/zod/src/v4/core/to-json-schema.ts
📚 Learning: 2025-10-21T17:27:32.492Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/zod-internals.mdc:0-0
Timestamp: 2025-10-21T17:27:32.492Z
Learning: Applies to packages/zod/src/v4/core/schemas.ts : Implement schema parse functions following the standard structure: type check, push invalid_type issue on mismatch, optionally coerce/transform, and return payload

Applied to files:

  • packages/zod/src/v4/core/to-json-schema.ts
📚 Learning: 2025-10-21T17:26:08.288Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/testing-guidelines.mdc:0-0
Timestamp: 2025-10-21T17:26:08.288Z
Learning: Applies to packages/zod/src/{v4/classic/tests,v4/core/tests,v3/tests}/**/*.test.ts : Use import type for type-only imports in tests (e.g., `import type { ... }`)

Applied to files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
📚 Learning: 2025-10-21T17:26:08.288Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/testing-guidelines.mdc:0-0
Timestamp: 2025-10-21T17:26:08.288Z
Learning: Applies to packages/zod/src/{v4/classic/tests,v4/core/tests,v3/tests}/**/*.test.ts : Do not skip tests due to type issues; fix the types instead

Applied to files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
📚 Learning: 2025-10-21T17:27:32.492Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/zod-internals.mdc:0-0
Timestamp: 2025-10-21T17:27:32.492Z
Learning: Applies to packages/zod/src/v4/core/errors.ts : Define and use canonical error types/codes as declared in errors.ts (e.g., invalid_type, TooBig/TooSmall, InvalidStringFormat, InvalidUnion, Custom)

Applied to files:

  • packages/zod/src/v4/core/to-json-schema.ts
📚 Learning: 2025-10-21T17:26:08.288Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/testing-guidelines.mdc:0-0
Timestamp: 2025-10-21T17:26:08.288Z
Learning: Applies to packages/zod/src/{v4/classic/tests,v4/core/tests,v3/tests}/**/*.test.ts : Import Zod in tests as `import * as z from "zod/v4"`

Applied to files:

  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
📚 Learning: 2025-10-21T17:27:32.492Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/zod-internals.mdc:0-0
Timestamp: 2025-10-21T17:27:32.492Z
Learning: Applies to packages/zod/src/v4/core/schemas.ts : Define computed internal properties using util.defineLazy() to avoid circular dependencies

Applied to files:

  • packages/zod/src/v4/core/to-json-schema.ts
📚 Learning: 2025-10-21T17:26:08.288Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/testing-guidelines.mdc:0-0
Timestamp: 2025-10-21T17:26:08.288Z
Learning: Applies to packages/zod/src/{v4/classic/tests,v4/core/tests,v3/tests}/**/*.test.ts : Keep test suites concise while maintaining adequate coverage

Applied to files:

  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
📚 Learning: 2025-10-21T17:26:08.288Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/testing-guidelines.mdc:0-0
Timestamp: 2025-10-21T17:26:08.288Z
Learning: Applies to packages/zod/src/{v4/classic/tests,v4/core/tests,v3/tests}/**/*.test.ts : Permanent, regression, API validation, edge-case coverage, and performance benchmark tests must be in the test suite (not play.ts)

Applied to files:

  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
📚 Learning: 2025-10-21T17:26:32.924Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/testing-workflow.mdc:0-0
Timestamp: 2025-10-21T17:26:32.924Z
Learning: Applies to packages/zod/src/v4/{classic,core}/tests/**/*.test.ts : Place tests under packages/zod/src/v4/classic/tests/ or packages/zod/src/v4/core/tests/

Applied to files:

  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
📚 Learning: 2025-10-21T17:26:08.288Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/testing-guidelines.mdc:0-0
Timestamp: 2025-10-21T17:26:08.288Z
Learning: Applies to packages/zod/src/{v4/classic/tests,v4/core/tests,v3/tests}/**/*.test.ts : Use descriptive file names like string.test.ts, object.test.ts, url-validation.test.ts and group related functionality together

Applied to files:

  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
📚 Learning: 2025-10-21T17:26:32.924Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/testing-workflow.mdc:0-0
Timestamp: 2025-10-21T17:26:32.924Z
Learning: Applies to packages/zod/src/v4/{classic,core}/tests/**/*.test.ts : Use test() for individual test cases

Applied to files:

  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
📚 Learning: 2025-10-21T17:26:08.288Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/testing-guidelines.mdc:0-0
Timestamp: 2025-10-21T17:26:08.288Z
Learning: Applies to packages/zod/src/{v4/classic/tests,v4/core/tests,v3/tests}/**/*.test.ts : Write tests with clear, descriptive names and cover both success and failure cases

Applied to files:

  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
📚 Learning: 2025-10-21T17:26:32.924Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: .cursor/rules/testing-workflow.mdc:0-0
Timestamp: 2025-10-21T17:26:32.924Z
Learning: Applies to packages/zod/src/v4/{classic,core}/tests/**/*.test.ts : Use expect() for assertions

Applied to files:

  • packages/zod/src/v4/classic/tests/to-json-schema.test.ts
🧬 Code graph analysis (1)
packages/zod/src/v4/core/to-json-schema.ts (3)
packages/zod/src/v3/types.ts (3)
  • options (3043-3045)
  • options (3166-3168)
  • options (4112-4114)
packages/zod/src/v4/classic/schemas.ts (1)
  • json (2182-2188)
packages/zod/src/v4/mini/schemas.ts (1)
  • json (1655-1660)
🔇 Additional comments (1)
packages/zod/src/v4/core/to-json-schema.ts (1)

333-346: Need to verify the type of def in the union case. Let me check the context in to-json-schema.ts:Perfect! Issue #4873 directly addresses this: the discriminator key is incorrectly typed as $ZodUnionDef instead of $ZodDiscriminatedUnionDef. This confirms that the type cast in the code under review is indeed a known workaround for a type system limitation.

Based on the verification:

  1. The first script output confirmed that $ZodDiscriminatedUnionDef interface properly defines discriminator: Disc on line 2119
  2. The second script showed that the code properly uses def.discriminator without issues in the runtime at lines 2164, 2190, and 2205
  3. The web search revealed that the type mismatch is actually a known issue (#4873) where the TypeScript type system doesn't properly reflect that discriminated union defs have the discriminator property
  4. The logic for routing to oneOf vs anyOf matches JSON Schema specifications and aligns with Zod's design

All my learnings confirm the logic is sound. The code handles discriminator access correctly, even though the type system requires a workaround.

The discriminator property check is correct—discriminated unions are guaranteed to have this property.

The logic properly distinguishes between regular unions (using anyOf) and discriminated unions (using oneOf), which aligns with JSON Schema semantics. The type cast (def as any) is a necessary workaround for a TypeScript type mismatch that's documented in Zod issue #4873, but the runtime behavior is solid.

Copy link

@pawk3k pawk3k left a comment

Choose a reason for hiding this comment

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

Good

@colinhacks
Copy link
Owner

colinhacks commented Nov 18, 2025

Great call, love this.

@enzoferey
Copy link

Hey team, it seems this didn't land on [email protected] while using zod/v4, any chances to get it backported?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v4: toJSONSchema union oneOf issue

4 participants