Superceded by #3407
I'm planning to deprecate z.discriminatedUnion in favor of a "switch" API that's cleaner and more generalizable. You can dynamically "switch" between multiple schemas at parse-time based on the input.
const schema = z.switch(()=>{
return Math.random() ? z.string() : z.number()
});
I expand more on the z.switch API later. Let's talk about z.discriminatedUnion.
Why
z.union naively tries each union element until parsing succeeds. That's slow and bad. Zod needed some solution.
z.discriminatedUnion was a mistake. The API was good but I had reservations about the implementation. It required fiddly recursive logic to exrtract a literal discriminator key from each union element.

It's a bad sign when a method or class requires weird recursive traversal of other schemas. For starters, Zod is designed to be subclassable. Users can theoretically subclass ZodType to implement custom schema types. But logic like this instanceof switch statement don't and can't account for any user-land schema types.
But the main problem is just that this kind of pattern is bad and introduces a lot of edge cases. It means that only certain kinds of schemas are allowed as discriminators, and others will fail in unexpected ways. There are now dozens of issues that have been opened regarding these various edge cases. The PRs attempting to solve this problem are irredeemably complex and introduce even more edge cases.
The .deepPartial API has this same problem. I'm deprecating it for the same reason.
Many of those issues are asking for non-literal discriminator types:
type MyUnion =
| { type: "a", value: string }
| { type: "b", value: string }
| { type: "c", value: string }
| { type: string, value: string }
| { type: null, value: string }
| { type: undefined, value: string }
| { type: MyEnum, value: string }
| { type: { nested: string }, value: string }
| { type: number[], value: string };
Imagine each of those elements are represented with Zod schemas. Zod would need to extract the type field from each of these elements and find a way to match the incoming input.type against those options. In the general case, Zod would extract the type field from the shape of each component ZodObject and check input.type against those schemas until a match is found. At that point, we're back to doing a parse operation for each element of the union, which is what z.discriminatedUnion is supposed to avoid doing. (It's still doing less work than the naive z.union but still.)
Another issue is composability. The existing API expects the second argument to be an array of ZodObject schemas.
z.discriminatedUnion("type", [
z.object({ type: z.literal("a"), value: z.string() }),
z.object({ type: z.literal("b"), value: z.string() }),
])
This isn't composable, in that you can't nest discriminated unions or add additional members.
const ab = z.discriminatedUnion("type", [
z.object({ type: z.literal("a"), value: z.string() }),
z.object({ type: z.literal("b"), value: z.string() }),
]);
const abc = z.discriminatedUnion("type", [
ab,
z.object({ type: z.literal("c"), value: z.string() }),
]);
Yes, Zod could support both (ZodObject | ZodDiscriminatedUnion)[] as union members, but that requires additional messy logic that reflects a more fundamental problem with the API. It also makes increasingly difficult to enforce typesafety on the union - it's important that all union elements have a type property, otherwise the union is no longer discriminable.
Replacement: z.switch
const schema = z.switch(input => {
return (typeof input) === "string" ? z.string() : z.number();
})
schema.parse("whatever"); // string | number
A discriminated union looks like this:
const schema = z.switch((input) => {
switch(input.key){
case "a":
return z.object({ key: z.literal("a"), value: z.string() })
case "b":
return z.object({ key: z.literal("b"), value: z.number() })
default:
return z.never()
}
});
schema.parse({ /* data */ });
// { key: 'a', value: 'asdf' } | { key: 'b', value: number }
Ultimately the z.switch API is a far more explicit and generalizable API. Zod doesn't do any special handling. The user specifies exactly how the input will be used to select the schema. z.switch() accepts a function. The ResultType of that function is inferred. It will be the union of the schema types returned along all code paths in the function. For instance:
const schema = z.switch(()=>{
return Math.random() ? z.string() : z.number()
});
Zod sees that the return type of the switcher function is ZodString | ZodNumber. The result of the z.switch is ZodSwitch<ZodString | ZodNumber>. The result of schema.parse(...) is string | number.
You can represent discriminated unions explicitly like this:
const schema = z.switch((input) => {
switch(input.key){
case "a":
return z.object({ key: z.literal("a"), value: z.string() })
case "b":
return z.object({ key: z.literal("b"), value: z.number() })
default:
return z.never()
}
});
schema.parse({ /* data */ });
// { key: 'a', value: 'asdf' } | { key: 'b', value: number }
This can be written in a more condensed form like so:
const schema = z.switch((input) => ({
a: z.object({ key: z.literal("a"), value: z.string() }),
b: z.object({ key: z.literal("b"), value: z.number() }),
}[input.key as string]));
schema.parse({ key: 'a', value: 'asdf' });
// { key: 'a', value: 'asdf' } | { key: 'b', value: number }
It's marginally more verbose. It's also explicit, closes 30+ issues, eliminates a lot of hairy logic, and lets Zod represent the full scope of TypeScript's type system. z.discrimininatedUnion is too fragile and causes too much confusion so it needs to go.
Superceded by #3407
I'm planning to deprecate
z.discriminatedUnionin favor of a "switch" API that's cleaner and more generalizable. You can dynamically "switch" between multiple schemas at parse-time based on the input.I expand more on the
z.switchAPI later. Let's talk aboutz.discriminatedUnion.Why
z.unionnaively tries each union element until parsing succeeds. That's slow and bad. Zod needed some solution.z.discriminatedUnionwas a mistake. The API was good but I had reservations about the implementation. It required fiddly recursive logic to exrtract a literal discriminator key from each union element.It's a bad sign when a method or class requires weird recursive traversal of other schemas. For starters, Zod is designed to be subclassable. Users can theoretically subclass
ZodTypeto implement custom schema types. But logic like thisinstanceofswitch statement don't and can't account for any user-land schema types.But the main problem is just that this kind of pattern is bad and introduces a lot of edge cases. It means that only certain kinds of schemas are allowed as discriminators, and others will fail in unexpected ways. There are now dozens of issues that have been opened regarding these various edge cases. The PRs attempting to solve this problem are irredeemably complex and introduce even more edge cases.
discriminatedUnionwithlazy? #1504discriminatedUnionerrors withz.object().transform()#1477.union()vs.discriminatedUnion()#1424discriminatedUnionproduces TS error when.defaultor.preprocessare applied #1490Many of those issues are asking for non-literal discriminator types:
Imagine each of those elements are represented with Zod schemas. Zod would need to extract the
typefield from each of these elements and find a way to match the incominginput.typeagainst those options. In the general case, Zod would extract thetypefield from the shape of each componentZodObjectand checkinput.typeagainst those schemas until a match is found. At that point, we're back to doing a parse operation for each element of the union, which is whatz.discriminatedUnionis supposed to avoid doing. (It's still doing less work than the naivez.unionbut still.)Another issue is composability. The existing API expects the second argument to be an array of
ZodObjectschemas.This isn't composable, in that you can't nest discriminated unions or add additional members.
Yes, Zod could support both
(ZodObject | ZodDiscriminatedUnion)[]as union members, but that requires additional messy logic that reflects a more fundamental problem with the API. It also makes increasingly difficult to enforce typesafety on the union - it's important that all union elements have atypeproperty, otherwise the union is no longer discriminable.Replacement:
z.switchA discriminated union looks like this:
Ultimately the
z.switchAPI is a far more explicit and generalizable API. Zod doesn't do any special handling. The user specifies exactly how theinputwill be used to select the schema.z.switch()accepts a function. TheResultTypeof that function is inferred. It will be the union of the schema types returned along all code paths in the function. For instance:Zod sees that the return type of the switcher function is
ZodString | ZodNumber. The result of thez.switchisZodSwitch<ZodString | ZodNumber>. The result ofschema.parse(...)isstring | number.You can represent discriminated unions explicitly like this:
This can be written in a more condensed form like so:
It's marginally more verbose. It's also explicit, closes 30+ issues, eliminates a lot of hairy logic, and lets Zod represent the full scope of TypeScript's type system.
z.discrimininatedUnionis too fragile and causes too much confusion so it needs to go.