zod icon indicating copy to clipboard operation
zod copied to clipboard

feature request: combining/nesting/extending discriminated unions

Open mcky opened this issue 3 years ago • 8 comments

In our app we have some data coming from an external service that's modelled as a combination of 2 discriminated unions, e.g.

[
  { type: "quote", attribution: "..." },
  { type: "textAndMedia", mediaType: "image", image: {...} }
  { type: "textAndMedia", mediaType: "video", video: {...} }
]

But attempting to model it with zod fails with the error:
The discriminator value could not be extracted from all the provided schemas

const t = z.discriminatedUnion("type", [
  z.object({ type: z.literal("one"), foo: z.string() }),
  z.object({ type: z.literal("two"), bar: z.string() }),
  z.discriminatedUnion("secondType", [
    { type: z.literal("three"), secondType: z.literal("a") },
  ]),
]);

An extra problem we've ran into is the nested discriminator is defined elsewhere, but unlike objects it doesn't have a .extend method, and a ZodDiscriminatedUnion isn't a valid arg for ZodObject.merge

const otherDiscriminator = z.discriminatedUnion("secondType", [
  z.object({ secondType: z.literal("a") }),
  z.object({ secondType: z.literal("b") }),
]);
z.discriminatedUnion("type", [
  z.object({ type: z.literal("one"), foo: z.string() }),
  z.object({ type: z.literal("two"), bar: z.string() }),
  otherDiscriminator.extend({ type: z.literal("three") }),
]);
Instead of extending I considered extracting the discriminator options and mapping over them, but zod can't infer that correctly. Code sample in toggle

const otherDiscriminatorOptions = [
  z.object({ secondType: z.literal("a") }),
  z.object({ secondType: z.literal("b") }),
] as const;

const otherDiscriminator = z.discriminatedUnion("secondType", [
  ...otherDiscriminatorOptions,
]);

z.discriminatedUnion("type", [
  z.object({ type: z.literal("one"), foo: z.string() }),
  z.object({ type: z.literal("two"), bar: z.string() }),

  z.discriminatedUnion("secondType", [
    ...otherDiscriminatorOptions.map((option) => ({
      type: z.literal("three"),
      ...option,
    })),
  ]),
]);

mcky avatar Sep 30 '22 14:09 mcky

Similar need here.

In our case we are trying to express hierarchy between Entities. When we get to nest the discriminated union representing the parent objects we get this error, probably because the resulting unions/discriminatedUnions don't have a shape containing the discriminator property.

const Child = z.object({ id: z.string(), _type: 'Child' });
const Parent = z.discriminatedUnion('_type', [
  z.object({ id: z.string(), _type: z.literal('Parent') }),
  Child,
]);
z.discriminatedUnion('_type', [
  Parent,
  Child,
])

sdirosa avatar Oct 04 '22 12:10 sdirosa

Similar need here.

In our case we are trying to express hierarchy between Entities. When we get to nest the discriminated union representing the parent objects we get this error, probably because the resulting unions/discriminatedUnions don't have a shape containing the discriminator property.

@sdirosa I was just trying to check how similar our examples are and maybe part of your example was left out?

Parent is already a discriminated union of Parent | Child, is there some nesting that was missed? _type: 'Child' needs wrapping in a z.literal too (assuming this is a typo from generating a repro example)

const Child = z.object({ id: z.string(), _type: z.literal('Child') });
const Parent = z.discriminatedUnion('_type', [
  z.object({ id: z.string(), _type: z.literal('Parent') }),
  Child,
]);

Parent.parse({ _type: 'Child', id: 'child-id' }) // => { _type: 'Child', id: 'child-id' }
Parent.parse({ _type: 'Parent', id: 'parent-id' }) // => { _type: 'Parent', id: 'parent-id' }
 type Child = { _type: 'Child'; id: string;}
 type Parent = 
   | { _type: "Parent"; id: string; }
   | Child

mcky avatar Oct 04 '22 12:10 mcky

@mcky What I experience is an exception thrown when I try to create the discriminatedUnion of Child and Parent. Sorry, I simplified the code because our model is much more verbose and didn't want to add noise here.

To clarify, this is the statement that throws the exception

z.discriminatedUnion('_type', [
  Parent, // discriminatedUnion
  Child,  // Object
]);

The full code would be:

const Child = z.object({ id: z.string(), _type: z.literal('Child') });
const Parent = z.discriminatedUnion('_type', [
  z.object({ id: z.string(), _type: z.literal('Parent') }),
  Child,
]);

Parent.parse({ _type: 'Child', id: 'child-id' }) // => { _type: 'Child', id: 'child-id' }
Parent.parse({ _type: 'Parent', id: 'parent-id' }) // => { _type: 'Parent', id: 'parent-id' }

const People = z.discriminatedUnion('_type', [ // => throws
  Parent,
  Child,
]);

Basically, we are trying to nest a number of discriminated unions sharing the same discriminator property.

sdirosa avatar Oct 04 '22 13:10 sdirosa

Wanted to bump as having the ability to use a discriminated union with extend/merge would be really, really nice!

jasonsilberman avatar Oct 13 '22 01:10 jasonsilberman

@mcky @sdirosa wanted to make you aware of some WIP work I've taken on re: this in https://github.com/colinhacks/zod/pull/1589. I'm looking around for expected use cases and ran into this issue; can you please take a look and see if it'd resolve for your use case?

Particularly @mcky based on your comment I'm unsure of your expected resulting type in your example. Do you mean to use different discriminator values for each nested discriminated union? Taken from your comment:

const t = z.discriminatedUnion("type", [
  z.object({ type: z.literal("one"), foo: z.string() }),
  z.object({ type: z.literal("two"), bar: z.string() }),
  z.discriminatedUnion("secondType", [ // is this intended to be different from `type`?
    { type: z.literal("three"), secondType: z.literal("a") },
    // a discriminatedUnion needs two or more elements to make a union
  ]),
]);

z.infer<typeof t> // what is the expected TS type here?

Currently having different discriminator keys in the union of entities will throw. Otherwise I think it would just be a union and you'd just loop through the unions and see if one parses.

maxArturo avatar Nov 25 '22 11:11 maxArturo

@maxArturo nice!

I can confirm that your changes to allow using a ZodDiscriminatedUnion as an element of other discriminated unions fixes the issue. Requiring the discriminated unions to share the same discrimination key seems reasonable to me.

In our specific case all of the unions we are discriminating, and occasionally nesting, have the same exact discrimination key. Also, the key is valued uniquely across all objects.

sdirosa avatar Nov 25 '22 13:11 sdirosa

@maxArturo

wanted to make you aware of some WIP work I've taken on re: this in https://github.com/colinhacks/zod/pull/1589. I'm looking around for expected use cases and ran into this issue; can you please take a look and see if it'd resolve for your use case?

Amazing! And thank you for taking the time to trawl through these issues

Particularly @mcky based on your comment I'm unsure of your expected resulting type in your example. Do you mean to use different discriminator values for each nested discriminated union? Taken from your comment:

Sorry that example in particular wasn't very clear. I actually thought myself that it was impossible to represent in TS at first, but I was able to before I made the issue

const t = z.discriminatedUnion("type", [
  z.object({ type: z.literal("one"), foo: z.string() }),
  z.object({ type: z.literal("two"), bar: z.string() }),
  z.discriminatedUnion("secondType", [
    // Each of these has the parent `type`, in addition to a `secondType`, e.g. if they were lifted out of the nested
    // union each would be a valid discriminator for the parent
    { type: z.literal("three"), secondType: z.literal("a") },
    { type: z.literal("three"), secondType: z.literal("b") },  // <-- with the extra union member
  ]),
]);

type SubExample = 
  | { secondType: "a" }
  | { secondType: "b" }

// This seems to be valid TS
// I'd expect `z.infer<typeof t> === Example`
type Example =
  | { type: "one"; foo: string }
  | { type: "two"; bar: string }
  | ({ type: "three" } & SubExample)

const tx: Example[] = [
  {type: "one", foo: "foo"},
  {type: "two", bar: "bar"},
  {type: "three", secondType: "a"},
  {type: "three", secondType: "b"},
]

I'll collapse for brevity but here's a more real world example we ran in to. In the end we were able to change the incoming data shape to avoid having cross over unions

Code sample in toggle

import { z } from 'zod'

// Existing types (from schemas) elsewhere in our codebase

const media = z.discriminatedUnion("mediaType", [
  z.object({ mediaType: z.literal("image"), image: z.object({}) }),
  z.object({ mediaType: z.literal("video"), video: z.object({}) }),
]);

// Inferred to
type Media = 
| { mediaType: "image"; image: {}}
| { mediaType: "video"; video: {}}

const quote = z.object({ text: z.string(), attribution: z.string() })

type Quote = { text: string; attribution: string; }

// Where we wanted to re-use them

const CMSBlock = z.discriminatedUnion("blockType", [
  quote.extend({ blockType: z.literal("quote") }),

  // Can't extend a discriminatedUnion
  // media.extend({ blockType: z.literal("media" )})

  // Nor merge one into an object
  // z.object({ blockType: z.literal("media") }).merge(media)

  // Seems like this would trigger the "different discriminator" error, and require redefining each of
  // the Media discriminators here
  // z.discriminatedUnion("mediaType", [
    // z.object({ blockType: "media", mediaType: z.literal("video") }),
    // z.object({ blockType: "media", mediaType: z.literal("image") }),
  // ])
])

type CMSBlock =
  | ({ blockType: "quote" } & Quote)
  | ({ blockType: "media" } & Media)

const blocks: CMSBlock[] = [
  {blockType: "quote", text: "lorem ipsum", attribution: "me"},
  {blockType: "media", mediaType: "image", image: {}},
  {blockType: "media", mediaType: "video", video: {}},
]

mcky avatar Nov 30 '22 22:11 mcky

@maxArturo And it seems #1589 will hopefully fix this other issue we have with boilerplate caused by enums being rejected

const documentSchema = z.object({id: z.string()})

// Currently we have lots of repeition because we can't nest enums/unions in disciminatedUnions
const old = z.discriminatedUnion("contentType", [
  z.object({ contentType: z.literal("webinar"), slug: z.string() })
    .merge(documentSchema),
  z.object({ contentType: z.literal("newsPost"), slug: z.string() })
    .merge(documentSchema),
  z.object({ contentType: z.literal("landingPage"), slug: z.string() })
    .merge(documentSchema),
  // .....
  // many many more of the same boilerplate omitted
  // ....
  z.object({ contentType: z.literal("homepage") })
    .merge(documentSchema),
  z.object({ contentType: z.literal("contactPage") })
    .merge(documentSchema),
  z.object({ contentType: z.literal("aboutPage") })
    .merge(documentSchema),
]);

// Seems like with your PR this would work
const withEnum = z.discriminatedUnion("contentType", [
  z.object({ contentType: z.enum(["webinar", "newsPost", "landingPage" /* etc etc */ ]), slug: z.string() })
    .merge(documentSchema),

  z.object({ contentType: z.enum(["homepage", "contactPage", "aboutPage" /* etc etc */ ]) })
    .merge(documentSchema),
]);

// And even handier in an ideal world, `.mege` can be called on the union to add additional keys
const withEnumAndMerge = z.discriminatedUnion("contentType", [
  z.object({ contentType: z.enum(["webinar", "newsPost", "landingPage" /* etc etc */ ]), slug: z.string() })
  z.object({ contentType: z.enum(["homepage", "contactPage", "aboutPage" /* etc etc */ ]) })
]).merge(documentSchema)

mcky avatar Nov 30 '22 22:11 mcky

Extension of discriminated unions would be really useful, because for now I have to do this:

z.discriminatedUnion(otherDiscriminatedUnion.discriminator, [
  otherDiscriminatedUnion.options[0].extend({ field: "value" }),
  otherDiscriminatedUnion.options[1].extend({ field: "value" }),
  otherDiscriminatedUnion.options[2].extend({ field: "value" }),
  otherDiscriminatedUnion.options[3].extend({ field: "value" }),
  otherDiscriminatedUnion.options[4].extend({ field: "value" }),
]);

rijenkii avatar Jan 29 '23 11:01 rijenkii

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] avatar Apr 29 '23 11:04 stale[bot]