import { expect, expectTypeOf, test } from "vitest"; import * as z from "zod/v4"; test("_values", () => { expect(z.string()._zod.values).toEqual(undefined); expect(z.enum(["a", "b"])._zod.values).toEqual(new Set(["a", "b"])); expect(z.nativeEnum({ a: "A", b: "B" })._zod.values).toEqual(new Set(["A", "B"])); expect(z.literal("test")._zod.values).toEqual(new Set(["test"])); expect(z.literal(123)._zod.values).toEqual(new Set([123])); expect(z.literal(true)._zod.values).toEqual(new Set([true])); expect(z.literal(BigInt(123))._zod.values).toEqual(new Set([BigInt(123)])); expect(z.undefined()._zod.values).toEqual(new Set([undefined])); expect(z.null()._zod.values).toEqual(new Set([null])); const t = z.literal("test"); expect(t.optional()._zod.values).toEqual(new Set(["test", undefined])); expect(t.nullable()._zod.values).toEqual(new Set(["test", null])); expect(t.default("test")._zod.values).toEqual(new Set(["test"])); expect(t.catch("test")._zod.values).toEqual(new Set(["test"])); const pre = z.preprocess((val) => String(val), z.string()).pipe(z.literal("test")); expect(pre._zod.values).toEqual(undefined); const post = z.literal("test").transform((_) => Math.random()); expect(post._zod.values).toEqual(new Set(["test"])); // Test that readonly literals pass through their values property expect(z.literal("test").readonly()._zod.values).toEqual(new Set(["test"])); }); test("valid parse - object", () => { expect( z .discriminatedUnion("type", [ z.object({ type: z.literal("a"), a: z.string() }), z.object({ type: z.literal("b"), b: z.string() }), ]) .parse({ type: "a", a: "abc" }) ).toEqual({ type: "a", a: "abc" }); }); test("valid - include discriminator key (deprecated)", () => { expect( z .discriminatedUnion("type", [ z.object({ type: z.literal("a"), a: z.string() }), z.object({ type: z.literal("b"), b: z.string() }), ]) .parse({ type: "a", a: "abc" }) ).toEqual({ type: "a", a: "abc" }); }); test("valid - optional discriminator (object)", () => { const schema = z.discriminatedUnion("type", [ z.object({ type: z.literal("a").optional(), a: z.string() }), z.object({ type: z.literal("b"), b: z.string() }), ]); expect(schema.parse({ type: "a", a: "abc" })).toEqual({ type: "a", a: "abc" }); expect(schema.parse({ a: "abc" })).toEqual({ a: "abc" }); }); test("valid - discriminator value of various primitive types", () => { const schema = z.discriminatedUnion("type", [ z.object({ type: z.literal("1"), val: z.string() }), z.object({ type: z.literal(1), val: z.string() }), z.object({ type: z.literal(BigInt(1)), val: z.string() }), z.object({ type: z.literal("true"), val: z.string() }), z.object({ type: z.literal(true), val: z.string() }), z.object({ type: z.literal("null"), val: z.string() }), z.object({ type: z.null(), val: z.string() }), z.object({ type: z.literal("undefined"), val: z.string() }), z.object({ type: z.undefined(), val: z.string() }), ]); expect(schema.parse({ type: "1", val: "val" })).toEqual({ type: "1", val: "val" }); expect(schema.parse({ type: 1, val: "val" })).toEqual({ type: 1, val: "val" }); expect(schema.parse({ type: BigInt(1), val: "val" })).toEqual({ type: BigInt(1), val: "val", }); expect(schema.parse({ type: "true", val: "val" })).toEqual({ type: "true", val: "val", }); expect(schema.parse({ type: true, val: "val" })).toEqual({ type: true, val: "val", }); expect(schema.parse({ type: "null", val: "val" })).toEqual({ type: "null", val: "val", }); expect(schema.parse({ type: null, val: "val" })).toEqual({ type: null, val: "val", }); expect(schema.parse({ type: "undefined", val: "val" })).toEqual({ type: "undefined", val: "val", }); expect(schema.parse({ type: undefined, val: "val" })).toEqual({ type: undefined, val: "val", }); const fail = schema.safeParse({ type: "not_a_key", val: "val", }); expect(fail.error).toBeInstanceOf(z.ZodError); }); test("invalid - null", () => { try { z.discriminatedUnion("type", [ z.object({ type: z.literal("a"), a: z.string() }), z.object({ type: z.literal("b"), b: z.string() }), ]).parse(null); throw new Error(); } catch (e: any) { // [ // { // code: z.ZodIssueCode.invalid_type, // expected: z.ZodParsedType.object, // input: null, // message: "Expected object, received null", // received: z.ZodParsedType.null, // path: [], // }, // ]; expect(e.issues).toMatchInlineSnapshot(` [ { "code": "invalid_type", "expected": "object", "message": "Invalid input: expected object, received null", "path": [], }, ] `); } }); test("invalid discriminator value", () => { const result = z .discriminatedUnion("type", [ z.object({ type: z.literal("a"), a: z.string() }), z.object({ type: z.literal("b"), b: z.string() }), ]) .safeParse({ type: "x", a: "abc" }); expect(result).toMatchInlineSnapshot(` { "error": [ZodError: [ { "code": "invalid_union", "errors": [], "note": "No matching discriminator", "discriminator": "type", "options": [ "a", "b" ], "path": [ "type" ], "message": "Invalid discriminator value. Expected 'a' | 'b'" } ]], "success": false, } `); }); test("invalid discriminator value - unionFallback", () => { const result = z .discriminatedUnion( "type", [z.object({ type: z.literal("a"), a: z.string() }), z.object({ type: z.literal("b"), b: z.string() })], { unionFallback: true } ) .safeParse({ type: "x", a: "abc" }); expect(result).toMatchInlineSnapshot(` { "error": [ZodError: [ { "code": "invalid_union", "errors": [ [ { "code": "invalid_value", "values": [ "a" ], "path": [ "type" ], "message": "Invalid input: expected \\"a\\"" } ], [ { "code": "invalid_value", "values": [ "b" ], "path": [ "type" ], "message": "Invalid input: expected \\"b\\"" }, { "expected": "string", "code": "invalid_type", "path": [ "b" ], "message": "Invalid input: expected string, received undefined" } ] ], "path": [], "message": "Invalid input" } ]], "success": false, } `); }); test("valid discriminator value, invalid data", () => { const result = z .discriminatedUnion("type", [ z.object({ type: z.literal("a"), a: z.string() }), z.object({ type: z.literal("b"), b: z.string() }), ]) .safeParse({ type: "a", b: "abc" }); // [ // { // code: z.ZodIssueCode.invalid_type, // expected: z.ZodParsedType.string, // message: "Required", // path: ["a"], // received: z.ZodParsedType.undefined, // }, // ]; expect(result).toMatchInlineSnapshot(` { "error": [ZodError: [ { "expected": "string", "code": "invalid_type", "path": [ "a" ], "message": "Invalid input: expected string, received undefined" } ]], "success": false, } `); }); test("wrong schema - missing discriminator", () => { // An option whose properties can be listed is checked when the union is constructed. expect(() => z.discriminatedUnion("type", [z.object({ value: z.string() })])).toThrow( /Invalid discriminated union option at index "0"/ ); expect(() => z.discriminatedUnion("type", [z.object({ type: z.literal("a"), a: z.string() }), z.object({ b: z.string() })]) ).toThrow(/Invalid discriminated union option at index "1"/); // An option whose shape cannot be listed without resolving it — a pipe here — is left to the lookup map, and fails on the first object parsed. const viaPipe = z.discriminatedUnion("type", [ z.object({ value: z.literal("x") }).pipe(z.object({ value: z.literal("x") })), ]); expect(() => viaPipe.safeParse({ value: "x" })).toThrow(/Invalid discriminated union option at index "0"/); }); test("the construction check follows shape through both of its phases", () => { // `shape` answers from the object the caller passed until the first read, then from a frozen copy. A key list derived at either moment would disagree with it at the other and reject an option that does carry the discriminator. const mutatedBeforeRead: Record = { value: z.string() }; const A = z.object(mutatedBeforeRead); mutatedBeforeRead.type = z.literal("a"); expect( z.discriminatedUnion("type", [A, z.object({ type: z.literal("b") })]).parse({ type: "a", value: "x" }) ).toEqual({ type: "a", value: "x" }); const mutatedAfterRead: Record = { type: z.literal("a"), value: z.string() }; const B = z.object(mutatedAfterRead); B.parse({ type: "a", value: "x" }); delete mutatedAfterRead.type; expect(() => z.discriminatedUnion("type", [B])).not.toThrow(); }); test("deriving a schema neither clobbers nor is inherited by the source", () => { // `.describe()` and friends reuse the def by identity, so the source keeps its own check. const A = z.object({ value: z.literal("x") }); A.describe("just documenting this"); expect(() => z.discriminatedUnion("type", [A])).toThrow(/Invalid discriminated union option/); expect(() => z.discriminatedUnion("type", [A.describe("d")])).toThrow(/Invalid discriminated union option/); // A def rebuilt by a builder is a different object, so it inherits nothing and is left to the lookup map. const Base = z.object({ status: z.literal("failed"), message: z.string() }); expect(() => z.discriminatedUnion("code", [Base.extend({ code: z.literal(400) })])).not.toThrow(); }); test("mutually-recursive getter options are checked without resolving them", () => { // `Object.keys` lists a shape's keys without invoking them, so the check sees `child` without running the getter that references the union being constructed. const variantA = z.object({ kind: z.literal("a"), get child() { return tree.optional(); }, }); const variantB = z.object({ kind: z.literal("b"), get sibling() { return tree.optional(); }, }); const tree = z.discriminatedUnion("kind", [variantA, variantB]); expect(tree.parse({ kind: "a", child: { kind: "b" } })).toEqual({ kind: "a", child: { kind: "b" } }); }); // removed to account for unions of unions // test("wrong schema - duplicate discriminator values", () => { // try { // z.discriminatedUnion("type",[ // z.object({ type: z.literal("a"), a: z.string() }), // z.object({ type: z.literal("a"), b: z.string() }), // ]); // throw new Error(); // } catch (e: any) { // expect(e.message.includes("Duplicate discriminator value")).toEqual(true); // } // }); test("async - valid", async () => { const schema = await z.discriminatedUnion("type", [ z.object({ type: z.literal("a"), a: z .string() .refine(async () => true) .transform(async (val) => Number(val)), }), z.object({ type: z.literal("b"), b: z.string(), }), ]); const data = { type: "a", a: "1" }; const result = await schema.safeParseAsync(data); expect(result.data).toEqual({ type: "a", a: 1 }); }); test("async - invalid", async () => { // try { const a = z.discriminatedUnion("type", [ z.object({ type: z.literal("a"), a: z .string() .refine(async () => true) .transform(async (val) => val), }), z.object({ type: z.literal("b"), b: z.string(), }), ]); const result = await a.safeParseAsync({ type: "a", a: 1 }); // expect(JSON.parse(e.message)).toEqual([ // { // code: "invalid_type", // expected: "string", // input: 1, // received: "number", // path: ["a"], // message: "Expected string, received number", // }, // ]); expect(result.error).toMatchInlineSnapshot(` [ZodError: [ { "expected": "string", "code": "invalid_type", "path": [ "a" ], "message": "Invalid input: expected string, received number" } ]] `); }); test("valid - literals with .default or .pipe", () => { const schema = z.discriminatedUnion("type", [ z.object({ type: z.literal("foo").default("foo"), a: z.string(), }), z.object({ type: z.literal("custom"), method: z.string(), }), z.object({ type: z.literal("bar").transform((val) => val), c: z.string(), }), ]); expect(schema.parse({ type: "foo", a: "foo" })).toEqual({ type: "foo", a: "foo", }); }); test("enum and nativeEnum", () => { enum MyEnum { d = 0, e = "e", } const schema = z.discriminatedUnion("key", [ z.object({ key: z.literal("a"), // Add other properties specific to this option }), z.object({ key: z.enum(["b", "c"]), // Add other properties specific to this option }), z.object({ key: z.nativeEnum(MyEnum), // Add other properties specific to this option }), ]); type schema = z.infer; expectTypeOf().toEqualTypeOf<{ key: "a" } | { key: "b" | "c" } | { key: MyEnum.d | MyEnum.e }>(); schema.parse({ key: "a" }); schema.parse({ key: "b" }); schema.parse({ key: "c" }); schema.parse({ key: MyEnum.d }); schema.parse({ key: MyEnum.e }); schema.parse({ key: "e" }); }); test("branded", () => { const schema = z.discriminatedUnion("key", [ z.object({ key: z.literal("a"), // Add other properties specific to this option }), z.object({ key: z.literal("b").brand<"asdfasdf">(), // Add other properties specific to this option }), ]); type schema = z.infer; expectTypeOf().toEqualTypeOf<{ key: "a" } | { key: "b" & z.core.$brand<"asdfasdf"> }>(); schema.parse({ key: "a" }); schema.parse({ key: "b" }); expect(() => { schema.parse({ key: "c" }); }).toThrow(); }); test("optional and nullable", () => { const schema = z.discriminatedUnion("key", [ z.object({ key: z.literal("a").optional(), a: z.literal(true), }), z.object({ key: z.literal("b").nullable(), b: z.literal(true), // Add other properties specific to this option }), ]); type schema = z.infer; expectTypeOf().toEqualTypeOf<{ key?: "a" | undefined; a: true } | { key: "b" | null; b: true }>(); schema.parse({ key: "a", a: true }); schema.parse({ key: undefined, a: true }); schema.parse({ key: "b", b: true }); schema.parse({ key: null, b: true }); expect(() => { schema.parse({ key: null, a: true }); }).toThrow(); expect(() => { schema.parse({ key: "b", a: true }); }).toThrow(); const value = schema.parse({ key: null, b: true }); if (!("key" in value)) value.a; if (value.key === undefined) value.a; if (value.key === "a") value.a; if (value.key === "b") value.b; if (value.key === null) value.b; }); test("multiple discriminators", () => { const FreeConfig = z.object({ type: z.literal("free"), min_cents: z.null(), }); // console.log(FreeConfig.shape.type); const PricedConfig = z.object({ type: z.literal("fiat-price"), // min_cents: z.int().nullable(), min_cents: z.null(), }); const Config = z.discriminatedUnion("type", [FreeConfig, PricedConfig]); Config.parse({ min_cents: null, type: "fiat-price", name: "Standard", }); expect(() => { Config.parse({ min_cents: null, type: "not real", name: "Standard", }); }).toThrow(); }); test("single element union", () => { const schema = z.object({ a: z.literal("discKey"), b: z.enum(["apple", "banana"]), c: z.object({ id: z.string() }), }); const input = { a: "discKey", b: "apple", c: {}, // Invalid, as schema requires `id` property }; // Validation must fail here, but it doesn't const u = z.discriminatedUnion("a", [schema]); const result = u.safeParse(input); expect(result).toMatchObject({ success: false }); expect(result).toMatchInlineSnapshot(` { "error": [ZodError: [ { "expected": "string", "code": "invalid_type", "path": [ "c", "id" ], "message": "Invalid input: expected string, received undefined" } ]], "success": false, } `); expect(u.options.length).toEqual(1); }); test("nested discriminated unions", () => { const BaseError = z.object({ status: z.literal("failed"), message: z.string() }); const MyErrors = z.discriminatedUnion("code", [ BaseError.extend({ code: z.literal(400) }), BaseError.extend({ code: z.literal(401) }), BaseError.extend({ code: z.literal(500) }), ]); const MyResult = z.discriminatedUnion("status", [ z.object({ status: z.literal("success"), data: z.string() }), MyErrors, ]); expect(MyErrors._zod.propValues).toMatchInlineSnapshot(` { "code": Set { 400, 401, 500, }, "status": Set { "failed", }, } `); expect(MyResult._zod.propValues).toMatchInlineSnapshot(` { "code": Set { 400, 401, 500, }, "status": Set { "success", "failed", }, } `); const result = MyResult.parse({ status: "success", data: "hello" }); expect(result).toMatchInlineSnapshot(` { "data": "hello", "status": "success", } `); const result2 = MyResult.parse({ status: "failed", code: 400, message: "bad request" }); expect(result2).toMatchInlineSnapshot(` { "code": 400, "message": "bad request", "status": "failed", } `); const result3 = MyResult.parse({ status: "failed", code: 401, message: "unauthorized" }); expect(result3).toMatchInlineSnapshot(` { "code": 401, "message": "unauthorized", "status": "failed", } `); const result4 = MyResult.parse({ status: "failed", code: 500, message: "internal server error" }); expect(result4).toMatchInlineSnapshot(` { "code": 500, "message": "internal server error", "status": "failed", } `); }); test("readonly literal discriminator", () => { const discUnion = z.discriminatedUnion("type", [ z.object({ type: z.literal("a").readonly(), a: z.string() }), z.object({ type: z.literal("b"), b: z.number() }), ]); // Test that both discriminator values are correctly included in propValues const propValues = discUnion._zod.propValues; expect(propValues?.type?.has("a")).toBe(true); expect(propValues?.type?.has("b")).toBe(true); // Test that the discriminated union works correctly const result1 = discUnion.parse({ type: "a", a: "hello" }); expect(result1).toEqual({ type: "a", a: "hello" }); const result2 = discUnion.parse({ type: "b", b: 42 }); expect(result2).toEqual({ type: "b", b: 42 }); // Test that invalid discriminator values are rejected expect(() => { discUnion.parse({ type: "c", a: "hello" }); }).toThrow(); }); test("pipes", () => { const schema = z .object({ type: z.literal("foo"), }) .transform((s) => ({ ...s, v: 2 })); expect(schema._zod.propValues).toMatchInlineSnapshot(` { "type": Set { "foo", }, } `); const schema2 = z.object({ type: z.literal("bar"), }); const combinedSchema = z.discriminatedUnion("type", [schema, schema2], { unionFallback: false, }); combinedSchema.parse({ type: "foo", v: 2, }); }); test("def", () => { const schema = z.discriminatedUnion( "type", [z.object({ type: z.literal("play") }), z.object({ type: z.literal("pause") })], { unionFallback: true } ); expect(schema.def).toBeDefined(); expect(schema.def.discriminator).toEqual("type"); expect(schema.def.unionFallback).toEqual(true); }); test("encode with codec discriminator", () => { const codec1 = z.codec(z.literal(1), z.literal("one"), { decode: () => "one" as const, encode: () => 1 as const, }); const codec2 = z.codec(z.literal(2), z.literal("two"), { decode: () => "two" as const, encode: () => 2 as const, }); const schema = z.discriminatedUnion("type", [ z.object({ type: codec1, value: z.string() }), z.object({ type: codec2, value: z.number() }), ]); // decode (forward) should work const decoded = schema.decode({ type: 1, value: "hello" }); expect(decoded).toEqual({ type: "one", value: "hello" }); // encode (backward) should also work — the discriminator values differ between forward (1, 2) and backward ("one", "two") directions const encoded = z.encode(schema, { type: "one", value: "hello" }); expect(encoded).toEqual({ type: 1, value: "hello" }); }); test("nested encoding does not select a sibling from forward discriminator values", async () => { const tag = (value: "a" | "b") => z.codec(z.literal(value).default(value), z.undefined(), { decode: () => undefined, encode: () => value, }); const inner = z.discriminatedUnion("type", [ z.object({ type: tag("a"), value: z.literal("a") }), z.object({ type: tag("b"), value: z.literal("b") }), ]); const outer = z.discriminatedUnion("type", [inner, z.object({ type: z.undefined(), value: z.string() })]); for (const value of ["a", "b"] as const) { const input = { type: undefined, value }; const expected = { type: value, value }; expect(z.encode(inner, input)).toEqual(expected); expect(z.encode(outer, input)).toEqual(expected); expect(await z.encodeAsync(outer, input)).toEqual(expected); } }); test("getDiscriminatedOption", () => { const fruit = z.object({ type: z.literal("fruit"), seeds: z.boolean() }); const veg = z.object({ type: z.literal("vegetable"), leafy: z.boolean() }); const schema = z.discriminatedUnion("type", [fruit, veg]); expect(z.getDiscriminatedOption(schema, "fruit")).toBe(fruit); expect(z.getDiscriminatedOption(schema, "vegetable")).toBe(veg); // The result is narrowed to the one member, not the union of all of them. expectTypeOf(z.getDiscriminatedOption(schema, "fruit")).toEqualTypeOf(); expectTypeOf(z.getDiscriminatedOption(schema, "vegetable")).toEqualTypeOf(); // @ts-expect-error — "unknown" is not a declared discriminator value z.getDiscriminatedOption(schema, "unknown"); }); test("getDiscriminatedOption — multi-value members and non-string discriminators", () => { const a = z.object({ type: z.literal(["x", "y"]), payload: z.string() }); const b = z.object({ type: z.literal("z"), payload: z.number() }); const multi = z.discriminatedUnion("type", [a, b]); expect(z.getDiscriminatedOption(multi, "x")).toBe(a); expect(z.getDiscriminatedOption(multi, "y")).toBe(a); expect(z.getDiscriminatedOption(multi, "z")).toBe(b); const num = z.object({ type: z.literal(1) }); const bool = z.object({ type: z.literal(true) }); const nul = z.object({ type: z.null() }); const mixed = z.discriminatedUnion("type", [num, bool, nul]); expect(z.getDiscriminatedOption(mixed, 1)).toBe(num); expect(z.getDiscriminatedOption(mixed, true)).toBe(bool); expect(z.getDiscriminatedOption(mixed, null)).toBe(nul); // An omittable discriminator claims undefined, so it resolves like any other value. const opt = z.object({ type: z.literal("a").optional(), a: z.string() }); const req = z.object({ type: z.literal("b"), b: z.string() }); expect(z.getDiscriminatedOption(z.discriminatedUnion("type", [opt, req]), undefined)).toBe(opt); }); test("getDiscriminatedOption caches in the bag and costs nothing until called", () => { const schema = z.discriminatedUnion("type", [z.object({ type: z.literal("a") }), z.object({ type: z.literal("b") })]); expect(schema._zod.bag.optionsMap).toBeUndefined(); expect(z.getDiscriminatedOption(schema, "a")).toBe(schema.options[0]); const map = schema._zod.bag.optionsMap; expect(map).toBeInstanceOf(Map); expect(z.getDiscriminatedOption(schema, "b")).toBe(schema.options[1]); expect(schema._zod.bag.optionsMap).toBe(map); // A clone recomputes rather than inheriting the cache. expect(schema.clone()._zod.bag.optionsMap).toBeUndefined(); }); test.each(["__proto__", "constructor", "toString", "hasOwnProperty", "valueOf"])( "Object.prototype discriminator name: %s", (key) => { const first = z.object({ [key]: z.literal("a"), value: z.string() }); const second = z.object({ [key]: z.literal("b"), value: z.number() }); const schema = z.discriminatedUnion(key, [first, second]); expect(schema._zod.propValues?.[key]).toEqual(new Set(["a", "b"])); const input = Object.fromEntries([ [key, "a"], ["value", "ok"], ]); const parsed: any = schema.parse(input); expect(Object.prototype.hasOwnProperty.call(parsed, key)).toBe(key !== "__proto__"); if (key !== "__proto__") expect(parsed[key]).toBe("a"); expect(parsed.value).toBe("ok"); } ); test("an omittable discriminator claims undefined", () => { const omittable = [ z.exactOptional(z.literal("a")), z.optional(z.literal("a")), z.literal("a").default("a"), z.literal("a").prefault("a"), z.literal("a").catch("a"), ]; for (const k of omittable) { expect(z.object({ k })._zod.propValues.k).toEqual(new Set(["a", undefined])); } // one option omits the key: it claims undefined, so an absent key routes there and the union agrees const options = [ z.object({ k: z.exactOptional(z.literal("a")), x: z.string() }), z.object({ k: z.literal("b"), y: z.number() }), ] as const; expect(z.discriminatedUnion("k", options).safeParse({ x: "s" }).success).toEqual(true); expect(z.union(options).safeParse({ x: "s" }).success).toEqual(true); expect(z.discriminatedUnion("k", options).safeParse({ k: "b", y: 1 }).success).toEqual(true); // ambiguous absence does not prevent explicit tags from routing for (const k of omittable) { const schema = z.discriminatedUnion("k", [z.object({ k }), z.object({ k: z.exactOptional(z.literal("c")) })]); expect(schema.parse({ k: "a" })).toEqual({ k: "a" }); expect(schema.parse({ k: "c" })).toEqual({ k: "c" }); expect(schema.safeParse({}).success).toBe(false); } }); test("defaulted discriminators preserve tagged parsing without guessing a member", async () => { const a = z.object({ type: z.literal("a").default("a"), x: z.number().positive() }); const b = z.object({ type: z.literal("b").default("b"), y: z.string() }); const c = z.object({ type: z.literal("c").default("c"), z: z.boolean() }); for (const options of [ [a, b, c], [c, b, a], ] as const) { const schema = z.discriminatedUnion("type", options); for (const input of [a.parse({ x: 1 }), b.parse({ y: "s" }), c.parse({ z: true })]) { expect(schema.parse(input)).toEqual(input); expect((await schema.safeParseAsync(input)).success).toBe(true); } for (const input of [{ x: 1, y: "s", z: true }, { type: undefined }, { type: "other" }, { type: "a", x: -1 }]) { expect(schema.safeParse(input).success).toBe(false); expect((await schema.safeParseAsync(input)).success).toBe(false); } const result = schema.safeParse({}); expect(result.error?.issues[0]).toMatchObject({ code: "invalid_union", path: ["type"], options: options.map((o) => o.shape.type.unwrap().value), }); expect(z.getDiscriminatedOption(schema, "a")).toBe(a); } const unique = z.discriminatedUnion("type", [a, b.safeExtend({ type: b.shape.type.unwrap() })]); expect(unique.parse({ x: 1 })).toEqual({ type: "a", x: 1 }); }); test("undefined collisions are value-based and discriminator lookup rejects ambiguity", () => { const a = z.object({ type: z.literal("a").optional() }); const absent = z.object({ type: z.undefined() }); for (const options of [ [a, absent], [absent, a], [absent, absent, a], ] as const) { const schema = z.discriminatedUnion("type", options); expect(schema.parse({ type: "a" })).toEqual({ type: "a" }); expect(schema.safeParse({}).success).toBe(false); expect(() => z.getDiscriminatedOption(schema, undefined)).toThrow('Ambiguous discriminator value "undefined"'); } const unique = z.discriminatedUnion("type", [absent, z.object({ type: z.literal("a") })]); expect(unique.parse({ type: undefined })).toEqual({ type: undefined }); expect(z.getDiscriminatedOption(unique, undefined)).toBe(absent); }); test("non-undefined discriminator collisions remain schema errors", () => { for (const tag of [z.literal("a").default("a"), z.literal(["a", "b"]), z.literal("a").nullable()]) { const schema = z.discriminatedUnion("type", [z.object({ type: tag }), z.object({ type: tag })]); expect(() => schema.safeParse({ type: "a" })).toThrow(/Duplicate discriminator value/); expect(() => z.encode(schema, { type: "a" })).toThrow(/Duplicate discriminator value/); expect(() => z.getDiscriminatedOption(schema, "a")).toThrow(/Duplicate discriminator value/); } const nullable = z.discriminatedUnion("type", [ z.object({ type: z.literal("a").nullable() }), z.object({ type: z.literal("b").nullable() }), ]); expect(() => nullable.safeParse({ type: "a" })).toThrow('Duplicate discriminator value "null"'); }); test("nested defaulted unions only advertise routable discriminator values", () => { const a = z.object({ type: z.literal("a").default("a"), group: z.literal("inner") }); const b = z.object({ type: z.literal("b").default("b"), group: z.literal("inner") }); const inner = z.discriminatedUnion("type", [a, b]); const c = z.object({ type: z.literal("c").default("c"), group: z.literal("outer") }); expect(inner._zod.propValues.type).toEqual(new Set(["a", "b"])); for (const schema of [ z.discriminatedUnion("type", [z.lazy(() => inner), c]), z.discriminatedUnion("group", [inner, c]), ]) { expect(schema.parse({ type: "a", group: "inner" })).toEqual({ type: "a", group: "inner" }); expect(schema.parse({ group: "outer" })).toEqual({ type: "c", group: "outer" }); expect(schema.safeParse({ group: "inner" }).success).toBe(false); } const fallback = z.discriminatedUnion("type", [a, b], { unionFallback: true }); expect(fallback._zod.propValues.type.has(undefined)).toBe(true); expect(fallback.parse({ group: "inner" })).toEqual({ type: "a", group: "inner" }); });