Skip to content

feat(v4): add union option to z.toJSONSchema for oneOf opt-in (#5807, #5493, #5494)#5930

Closed
dokson wants to merge 1 commit into
colinhacks:mainfrom
dokson:feat/json-schema-union-strategy
Closed

feat(v4): add union option to z.toJSONSchema for oneOf opt-in (#5807, #5493, #5494)#5930
dokson wants to merge 1 commit into
colinhacks:mainfrom
dokson:feat/json-schema-union-strategy

Conversation

@dokson
Copy link
Copy Markdown
Contributor

@dokson dokson commented May 1, 2026

Closes #5807. Also addresses #5493 and #5494 which surface the same need from a different consumer.

The problem

Multiple downstream consumers of z.toJSONSchema() reject the anyOf keyword that v4 emits for inclusive unions:

The default is sound, and stays put. As you noted in #5494 (comment):

Discriminated union -> oneOf
Union -> anyOf

This is sound and mirrors the validation behavior of these keywords in JSON Schema.

z.union(...) is inclusive — multiple branches can match the same value — so anyOf matches the spec semantics. This PR doesn't change that.

What this adds

A new opt-in parameter on z.toJSONSchema():

z.toJSONSchema(schema, { union: "oneOf" });

When set, inclusive unions emit oneOf instead of anyOf. Default is "anyOf".

Discriminated unions and z.xor() are unaffected — they're exclusive by construction and keep emitting oneOf regardless.

Why a flag and not just override

Users hit by this today work around it by walking the produced schema after the fact:

z.toJSONSchema(schema, {
  override: (ctx) => {
    if (ctx.zodSchema._zod.def.type === "union" && ctx.jsonSchema.anyOf) {
      ctx.jsonSchema.oneOf = ctx.jsonSchema.anyOf;
      delete ctx.jsonSchema.anyOf;
    }
  },
});

It works but it's brittle: it rewrites the path keys after they've been computed (the path arg in any user override already references anyOf indices), it has to be duplicated in every project that hits the issue, and it's not discoverable from the docs. A flag is one parameter, doesn't cost anything when unused, and the path is correct from the start.

Implementation

  • JSONSchemaGeneratorParams and ToJSONSchemaContext get a union: "anyOf" | "oneOf" field.
  • initializeContext defaults it to "anyOf".
  • unionProcessor picks the keyword once: oneOf if exclusive or opted in, else anyOf. The path argument and the output key are both written under the chosen keyword.
const keyword: "oneOf" | "anyOf" = isExclusive || ctx.union === "oneOf" ? "oneOf" : "anyOf";
const options = def.options.map((x, i) =>
  process(x, ctx as any, { ...params, path: [...params.path, keyword, i] })
);
json[keyword] = options;

Test coverage

5 tests in tests/to-json-schema-union.test.ts:

  • Default emits anyOf for inclusive union.
  • union: "oneOf" emits oneOf for inclusive union.
  • Explicit union: "anyOf" matches the default.
  • Discriminated unions stay on oneOf regardless of the flag.
  • Nested unions inside objects are also rewritten.

pnpm vitest run to-json-schema* — 344/344 passing locally with 0 type errors. No existing snapshots changed (default unchanged).

…olinhacks#5807, colinhacks#5493, colinhacks#5494)

Multiple consumers of `z.toJSONSchema` reject the `anyOf` keyword that
v4 emits for inclusive unions:

- Google Gemini Function Calling (colinhacks#5807) — schemas containing `anyOf`
  cause silent failures or validation errors.
- Swagger codegen tooling (colinhacks#5494) — generates broken bindings for
  `anyOf` shapes.
- Older OpenAPI / language-binding generators (colinhacks#5493) — same shape.

The default is sound and stays put — `z.union(...)` is non-exclusive by
spec, so `anyOf` mirrors validation semantics
(colinhacks#5494 (comment)).

Add an opt-in `union: "anyOf" | "oneOf"` parameter so users with
strict consumers can flip the encoding without resorting to a manual
`override` walk that intercepts every `anyOf` node:

```ts
z.toJSONSchema(schema, { union: "oneOf" });
```

Discriminated unions and `z.xor()` are unaffected — they always emit
`oneOf` because they are exclusive by construction.

Implementation:
- New `union` field on `JSONSchemaGeneratorParams` and
  `ToJSONSchemaContext`, defaulted to `"anyOf"` in `initializeContext`.
- `unionProcessor` reads `ctx.union` and chooses the keyword once,
  then writes the options under that key.

5 tests cover: default `anyOf` behavior, explicit `"anyOf"`, opt-in
`"oneOf"`, discriminated-union invariance, nested unions inside
objects.
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented May 1, 2026

TL;DR — Adds a union option to z.toJSONSchema() that lets callers opt into oneOf for inclusive unions, solving compatibility issues with Google Gemini Function Calling, Swagger codegen, and other consumers that reject anyOf.

Key changes

  • Add union: "anyOf" | "oneOf" parameter to z.toJSONSchema() — defaults to "anyOf" (preserving current behavior); when set to "oneOf", inclusive unions emit the oneOf keyword instead.
  • Simplify unionProcessor keyword selection — replaces branched if/else with a single computed keyword variable that respects both exclusivity and the new opt-in flag.
  • New test file for union option — covers default behavior, explicit opt-in, discriminated union invariance, and nested union rewriting.

Summary | 3 files | 1 commit | base: mainfeat/json-schema-union-strategy


union option for JSON Schema generation

Before: Inclusive unions always emitted anyOf; consumers like Gemini required a manual post-processing workaround via override.
After: Passing { union: "oneOf" } to z.toJSONSchema() emits oneOf for inclusive unions globally—path indices and output keys are both correct from the start.

The implementation touches three layers: the public JSONSchemaGeneratorParams interface gets the new field, initializeContext defaults it to "anyOf", and unionProcessor resolves the keyword once from isExclusive || ctx.union === "oneOf". Discriminated unions and z.xor() are unaffected—they remain oneOf unconditionally.

Why not just use override? The override approach rewrites keys after path computation, so any user override that references path indices (e.g. ["anyOf", 0]) sees stale values. The flag ensures path and output are consistent from the start.

to-json-schema.ts · json-schema-processors.ts · to-json-schema-union.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

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.

Reviewed — no issues found.

Pullfrog  | View workflow run | Using Claude Opus𝕏

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.

Reviewed — no issues found.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@colinhacks
Copy link
Copy Markdown
Owner

Very allergic to additional params on this function. I'm considering a plugin API that would give Zod the ability to encapsulate some of these common overrides and provides them to users via a consumable API.

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 anyOf incompatibility with Gemini API Function Calling

2 participants