diff --git a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts index c50df9d7b..a2e7573d4 100644 --- a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts +++ b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts @@ -657,6 +657,54 @@ describe("toJSONSchema", () => { `); }); + test("discriminated unions", () => { + const schema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("success"), data: z.string() }), + z.object({ type: z.literal("error"), message: z.string() }), + ]); + expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "data": { + "type": "string", + }, + "type": { + "const": "success", + "type": "string", + }, + }, + "required": [ + "type", + "data", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "message": { + "type": "string", + }, + "type": { + "const": "error", + "type": "string", + }, + }, + "required": [ + "type", + "message", + ], + "type": "object", + }, + ], + } + `); + }); + test("intersections", () => { const schema = z.intersection(z.object({ name: z.string() }), z.object({ age: z.number() })); diff --git a/packages/zod/src/v4/core/to-json-schema.ts b/packages/zod/src/v4/core/to-json-schema.ts index 429fd7614..d295ef258 100644 --- a/packages/zod/src/v4/core/to-json-schema.ts +++ b/packages/zod/src/v4/core/to-json-schema.ts @@ -330,13 +330,20 @@ export class JSONSchemaGenerator { } case "union": { const json: JSONSchema.BaseSchema = _json as any; + // Discriminated unions use oneOf (exactly one match) instead of anyOf (one or more matches) + // because the discriminator field ensures mutual exclusivity between options in JSON Schema + const isDiscriminated = (def as any).discriminator !== undefined; const options = def.options.map((x, i) => this.process(x, { ...params, - path: [...params.path, "anyOf", i], + path: [...params.path, isDiscriminated ? "oneOf" : "anyOf", i], }) ); - json.anyOf = options; + if (isDiscriminated) { + json.oneOf = options; + } else { + json.anyOf = options; + } break; } case "intersection": {