close
Skip to content

Deprecating z.discriminatedUnion? #2106

Description

@colinhacks

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.

Screenshot 2023-02-27 at 2 07 02 AM

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions