Repository navigation
Description
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.
- Enumerated discriminator #1773
- [BETA 3.20] Using transforms with discriminatedUnion #1667
- discriminatedUnion with recursive type results in ts error #1384
- Invalid type error with discriminatedUnion and or #1757
- Is there a way to combine
discriminatedUnionwithlazy? #1504 - Relationship between schema object fields #1136
discriminatedUnionerrors withz.object().transform()#1477- Transformed objects in
.union()vs.discriminatedUnion()#1424 - Zod discriminated union discriminator value accepts zod enum #1213
discriminatedUnionproduces TS error when.defaultor.preprocessare applied #1490- Type error with discriminatedUnion when using dynamic amount of objects #1181
- Allow more types in discriminated unions #1075
- feature request: combining/nesting/extending discriminated unions #1444
- Discriminated Union based on nested object property? #1868
- discriminatedUnion with array element as discriminator #1878
The
.deepPartialAPI 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() }),
]);- Nested discriminated unions #1618
- Nested discriminated unions #1610
- discriminated union cant be nested #1884
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 | numberA 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.


Activity
mgreenw commentedon Feb 27, 2023
While I really liked the simple API of
discriminatedUnionand am sad to see it go, this makes sense. Thank you for the detailed write up and your work to make a replacement that’s better!dvargasfuertes commentedon Feb 27, 2023
Any reason why
discriminatedUnioncan't now call the newswitchmethod in the way you described as its new implementation?Or, introduce a new API that just accepts an array of schemas that then calls the switch function under the hood.
One strength of zod is how closely defining schemas resembles defining the typescript types. Requiring a function to be defined in userland for unions deviates from how that user would define the discriminated union in standard typescript.
To solve the recursive issue, there could be a requirement that only allows top level discriminators, or that the discriminator defined would need to define the full path to the key in a lodashy way, like
key.subpath.JacobWeisenburger commentedon Feb 27, 2023
@colinhacks thanks for making a better system! Can't wait to try it out. Do you have an idea of when it will be released?
dgritsko commentedon Feb 28, 2023
Currently,
z.inferplays really nicely withz.discriminatedUnion. For example, given the following:This will result in
ABbeing defined as:Which is exactly what I would expect. How will type inference work with
z.switch-- will it support inference of "idiomatic" type definitions, similar to this example?pato1 commentedon Feb 28, 2023
I really like the new API as it opens up new options for building any validation logic.
A thing to consider is the type of
inputfor the switch function. In my opinion, you would almost always need to add switch after some preliminary validation.Given the example replacing distributed union you've provided:
you would probably want to validate
datais an object with propertykeyand some additional properties:And then you should have the
inputparameter of the switch function typed accordingly (input: {key: 'a' | 'b'}).Therefore I would suggest implementing switch similar to
ZodPipelinein that it can be added to existing validation.Then it could be used as follows:
Inability to compose distributed unions led me to build a temporary switch type based on the implementation of
ZodPipeline:Note, unlike ZodPipeline, if "base" validation failed, I had to return INVALID, as requirement for the switch function (
getScheme) parameter is not fulfilled.Inference works as expected:
Is this somewhat related to what you have in mind for the new API?
colinhacks commentedon Mar 1, 2023
@pato1 Almost exactly, aside from
base. I like the idea of having some "pre-validation" so you get some type safety before attempting the switch logic, and a.switch()method is certainly interesting. I'd propose to go a step further and overload the.pipe()API to accept(input: unknown)=>ZodType. They're conceptually the same -switchis a dynamicpipe.As with your
ZodSwitchclass, we just look at the return type of the "switcher function", which will be some union of all the schema types that are returned along the various code paths. Inference will work as expected. cc @dgritskocolinhacks commentedon Mar 1, 2023
Unfortunately though, over the course of trying to explain the problems with
z.discriminatedUnionI hit on an implementation that actually solves most of my issues with it.Conceptually, we could loop over the set of options (
ZodObject[]) in the discriminated union, pull out<option>.shape[discriminator], and use that to parseinput[discriminator]. The first time this succeeds, we have a match, and use thatoptionto validate the whole input. I was originally presenting this as a straw man for why ZodDiscriminatedUnion is inherently flawed...but actually it's a pretty good approach. With this, there's no need to spelunk into the internals of the union'soptionsto extract out literal discriminator values. There will be a slight performance tradeoff relative to the current implementation since we're still executing a.parseoperation for each option in the discriminated union. But most discriminator schemas should be simple & fast.Some special logic is required to make nesting work (passing other
ZodDiscriminatedUnioninstances intoz.discriminatedUnion), which is slightly annoying. It still may come back to bite me if people start asking to includeZodUnion,ZodEffects,ZodOptional, etc. as options inz.discriminatedUnion, at which point we're right back where we started, with a bunch of fiddly special-case logic.colinhacks commentedon Mar 1, 2023
Because the hard part is extracting out the literal discriminator values from the schemas passed in
ZodDiscriminatedUnion. We could rewritez.discriminatedUnionto return aZodSwitchbut that would be a breaking change - better to just leave it as is and let people switch over at their liesure.How is this different from the current API for
z.discriminatedUnion? The problem is that it's hard and messy.Remember that there is no syntax for "discriminated unions" in typescript. There are just unions. Zod only includes a separate API as a performance optimization when parsing unions, and I think
z.switchactually makes this more explicit.dvargasfuertes commentedon Mar 1, 2023
I realized here what I was proposing was actually the API of
z.union, which runs into the problem you described in your description.pato1 commentedon Mar 1, 2023
@colinhacks I like this idea, even more now you're considering rewriting
z.discriminatedUnion. But shouldn't then the "switcher function" accept(input: A["_output"])=>ZodType?Also one benefit to making
z.discriminatedUnionwork, is generators (Zod to X) like @anatine/zod-openapi.With the
switchAPI there would be no way of getting individual options for schema generation.This is just an idea, bear with me. One other way to achieve that even for other types of Options that closely resembles TypeScript would be to provide "indexed access". If types had a method, let's say
index(key: string): ZodTypethan you could just use that instead of<option>.shape[discriminator]. Every type that implemented this method (e.g.ZodDiscriminatedUnion) could then be used in discriminated union option. I can see it being useful even in other cases, for example if I want to validate only one field against some complex schema.Yes, it would be calling possibly deep for some complex schemas, but it would use public API of those types, instead of poking around in internals.
Pseudo examples:
sluukkonen commentedon Mar 4, 2023
FWIW, the issue can also be solved with an API like this
It's a bit more verbose, but the implementation doesn't have to assume anything about the structure of the inner types.
18 remaining items
denizdogan commentedon Dec 1, 2023
To anyone curious,
z.switchhas NOT been released (as of version 3.22.4)But it would be nice if some maintainer could clarify:
z.discriminatedUnion?z.switch?Add ability to use `z.discriminatedUnion` and `z.union` in schemas (#432
tomwidmer commentedon Jan 5, 2024
Will it be possible to, say, generate a JSON Schema or OpenAPI schema from a
switchschema? It seems unlikely, since there is no introspection. Wouldswitchwill break quite a bit of the zod eco-system? Obviously not all zod features are supported by all tools, but this would replace one that can usually be supported with one that can't.tomwidmer commentedon Jan 5, 2024
My use case is to have a type field in the parent object (so in a sibling property) - this is messy using the current discriminated union, and the
switchapproach doesn't fix it AFAICT.e.g.
where the
typefield determines which type should be in thechildfield.I believe
yupsupports this via thewhenfeature.mmkal commentedon Jan 12, 2024
@sluukkonen what about this:
Advantages:
z.literal(...)for each schemainput?.type === ...at runtime (so they'll usually be objects, but see above - we have az.union(...)and evenz.array(...)there)The internal implementation could be something like
Here's a typescript playground of the types working (minus the actual runtime
z.switch(...)part since that doesn't exist yet).@colinhacks would this avoid the pitfalls you mentioned about the previous
discriminatedUnionimplementation?Edit: Re-reading, this is pretty much what you suggested in the
more condensed formin the OP, but I think it's worth having the sugar. There's also a bug in my above implementation, in that excluding thetypefrom the RHS of theschemasparameter would mean it would be excluded from the parse result (sincez.object({a: z.string()}).parse({type: 'x', a: 'aaa'})=>{a: 'aaa'}). But if it were possible for thediscriminatedUnionimplementation to ensure thetypeprop is included in the parsed result, it's all the more useful.samchungy commentedon Jan 13, 2024
@mmkal The only problem I see with that is that you wouldn't be able to support discriminated unions on non-literal discriminator types like the OP states.
Though to be honest, I'm personally okay with that as a compromise if it makes maintaining this form of discriminated union easier. Mainstream validators like JSON Schema don't support that anyway I believe (correct me if I'm wrong).
uribracha2611 commentedon Jan 18, 2024
This sounds like a really cool idea! Could someone provide an update on the current status of this issue? Is there an active pull request, and perhaps share when it might be integrated into the library?
jadamduff commentedon Jan 22, 2024
I am also keen on finding some resolution to this conversation. I threw up a PR (#3171) that implements
z.switchas outlined by @colinhacks.I really like
z.switch. It provides a ton of flexibility in type discrimination and is marginally more performant thanz.discriminatedUnionin some cases.Thoughts?
fabian-hiller commentedon Feb 18, 2024
It seems to me that
z.switchis exactly the same asz.lazywith the difference thatz.switchpasses the input of the schema when calling the getter function. Zod could pass the input to the getter ofz.lazywithout a breaking change. This would allow people to freely choose betweenz.lazyandz.discriminatedUnion.suhjohn commentedon Mar 12, 2024
Hi, will this be resolved?
imahammou commentedon Apr 18, 2024
is this going somewhere ?
samchungy commentedon Apr 18, 2024
Likely no given the above issue
feat(v4): add `deepPartial` + `mapOnSchema` traversal primitive (coli…
feat(v4): add `deepPartial` + `mapOnSchema` traversal primitive (coli…