zod
zod copied to clipboard
Nested discriminated unions
I have a hierarchy of objects, where the first level uses a type
-property to discriminate, and the second level uses a subtype
.
An example diagram showing 3 instances on the first level (Foo, Bar & Baz), and then Baz having two subtypes under it (Able & Baker):
[root]
/ | \
Foo Bar Baz
/ \
Able Baker
It's possible to type this in TS with something like
type NestedDu =
{ type: "foo" }
| { type: "bar" }
| { type: "baz", subtype: "able" }
| { type: "baz", subtype: "baker" };
(Of course there are more fields on each property, but just keeping it minimal for the ease of understanding)
I tried to construct a Zod-schema using the following
const nestedDU = z.discriminatedUnion('type', [
z.object({
"type": z.literal("foo")
}),
z.object({
"type": z.literal("bar")
}),
z.discriminatedUnion('subtype', [
z.object({
"type": z.literal("baz"),
"subtype": z.literal("able")
}),
z.object({
"type": z.literal("baz"),
"subtype": z.literal("baker")
}),
]),
]);
But this doesn't work, since the outer discriminated union doesn't allow the inner one.
Is there any way to make this work as-is - or would it need additional work on Zod itself to make it work? I can try to create a PR if needed, but maybe it's already possible and I'm just missing something.
Kind regards Morten
@fangel I raised a similar issue at #1444 which @maxArturo might be addressing in #1589
@maxArturo Morten's description seems to better explain what I was after
Sorry, I looked through all the issues to try and find a similar one - but clearly I failed at that. But wonderful that someone else is potentially already looking in to this.
@mcky @fangel actually we'd need to revise #1589 a bit, since as it stands I wrote the implementation to keep a single discriminator
key across all nested schemas. At the onset it made sense, but I can also see this use case (different discriminator
keys in nested DiscriminatedUnion
schemas) being valid given that TS knows how to distinguish here.
I'll take a look at updating the PR to satisfy this use case. Thanks!
It could also be an option to allow an array of discriminators?
E.g.
const nestedDU = z.discriminatedUnion('type', [
z.object({
"type": z.literal("foo")
}),
z.object({
"type": z.literal("bar")
}),
z.discriminatedUnion(['type', 'subtype'], [
z.object({
"type": z.literal("baz"),
"subtype": z.literal("able")
}),
z.object({
"type": z.literal("baz"),
"subtype": z.literal("baker")
}),
]),
]);
Then the combination of the discriminator-values just needs to be unique... And it nests naively inside another DU with a subset of the discriminators.
Hey @fangel thanks for your suggestion! I'd initially be inclined to that, but it really restricts your use of it since you need to know the discriminator values beforehand. For example, I wouldn't be able to export a DU and reuse it across several other definitions, unless I wrap it inside a factory function/closure somehow.
I think TS definition types should be able to help us here... but at a first pass adding another discriminator type parameter gets weird very fast. Let me think on it a bit and see what we come up with. Feel free also to tinker with the PR and see if you get somewhere better!
Wouldn't reuse across other definitions get weird anyway, since you still need to know that the inner DU needs to have a discriminator for the outer DU as well as it's own?
I guess you could do it a factory like e.g.
const innerDUFactory = (innerDiscrimnatorName: string, innerDiscriminatorValue: z.AnyType) => z.discriminatedUnion([innerDiscrimnatorName, 'subtype'], [
z.object({
[innerDiscrimnatorName]: innerDiscriminatorValue,
"subtype": z.literal("able")
}),
z.object({
[innerDiscrimnatorName]: innerDiscriminatorValue,
"subtype": z.literal("baker")
}),
])
My thinking is that the inner-DU always needs to be a valid member of the outer-DU - just that it's less unique because it also needs the inner dimension to be unique.
Another option is to do it using some sort of union, where you have the properties for the outer-DU in one half of the union, and then the inner-DU in the other half of the union.
const nestedDU = z.discriminatedUnion('type', [
z.object({
"type": z.literal("foo")
}),
z.object({
"type": z.literal("bar")
}),
z.union([
z.object({
"type": z.literal("baz")
}),
z.discriminatedUnion('subtype', [
z.object({
"subtype": z.literal("able")
}),
z.object({
"subtype": z.literal("baker")
}),
]),
]),
]);
Sorry, the later example should of course be a merge of some sort, not union (it should be a AND, not a OR)
Hi @fangel! I think your factory solution (or really, any in-situ creation solution) could definitely work. I think it unfortunately breaks up the current API up a bit to ask people to do unions and then discriminated unions (though to be fair there's no API for nested DUs yet :) I just think it would be great to try and make it clean from the outset.
I think what I was trying to aim at was some sort of solution where we wouldn't have to add extra info, and it would be natural to do the following:
// in base.ts
export const baseDU = z.discriminatedUnion("baseType", [
z.object({
baseType: z.literal("first"),
specificType: z.literal("main"),
addenda: z.object({ alternate: z.literal(true) }),
}),
z.object({
baseType: z.literal("second"),
specificType: z.literal("other"),
}),
]);
// in specific.ts
import { baseDU } from "./base";
const specificDU = z.discriminatedUnion("specificType", [ // no need to define anything else here!
baseDU,
z.object({
specificType: z.literal("additional"),
extended: z.literal("true"),
}),
]);
I'd want the following to happen above
- correct inferred types from TS
- validation that the defined
discriminator
values are recursively enforced down the "DU tree", so that any additional discriminators would be enforced on eachz.object()
as existing keys
The actual non type implementation itself wouldn't be hard at all. We "bubble up" the raw objects of the child DU map's values, and the validation would work above in the following way for specificDU
:
- one entry has key:
additional
, value:z.object()
- second entry has key:
other
, value:z.object()
- third entry has key:
main
, value:z.object()
Where I'm currently stuck is I want a clever way to have TS recursively enforce the parent's DU in the child DU:
// not the real type but imagine a simplified version
type DU<
Discriminator extends String
> = {
discriminator: Discriminator;
duOptions: Array<DU<Discriminators>>; // this doesn't work, want a free unreferenced type variable
};
Note that this is a special case of some sort of type inference like this: playground . I'll see if I get anywhere shortly with that, otherwise I'll consider a simpler solution like what you suggested!
My half-though with the array of discriminators was that it could potentially be something that could be checked with a recursive type-definition
My thinking would be to recursively loop over the discriminators and the properties and match them up - if we exhaust the properties before the discriminators, then type type should resolve to never
. I believe this should be achievable with splitting the list up into the head & tail and seeing if the tail is empty.
Another option is to look into getting ZodObject.merge
to accept a discriminate union as the input, because then this would be achievable
z.discriminatedUnion('type', [
z.object({
"type": z.literal("foo")
}),
z.object({
"type": z.literal("bar")
}),
z.object({
"type": z.literal("baz")
}).merge(z.discriminatedUnion('subtype', [
z.object({
"subtype": z.literal("able")
}),
z.object({
"subtype": z.literal("baker")
}),
])),
]);
It currently fails because ZodDiscriminateUnion
isn't assignable to AnyZodObject
. I guess the semantics are a little weird since then .merge
would end up basically converting the original object to a union.
So semantically it's probably nicer to just have DUs nest the way you naively expect them to - i.e. adding the inner DU to the list of objects in the outer DU.
Hi @fangel thanks so much for your feedback! It really helped me think through the issues at hand and what would work best for this feature. So I came up with something that I think will allow the most flexibility:
- Create your DUs wherever, then add them in the "parent" DU
- add DUs in-line, and this will work too in both types and implementation
Do you mind taking a look at this commit in #1589 and see if it fits your use case? Thanks 🙏
Based on the test-schemas that looks exactly like what I envisioned it. On Monday I can see if I can use the PR-code running to test building the schema that I'm envisioning for my use-case.
I ran your PR-branch locally, and can confirm that it worked exactly like I'd hope it would. Wonderful work!
I am trying to achieve a similar nested Discriminated Union and it seems this PR solves that issue as well. Any idea when it might get merged, any way to help?
This is my use case:
const eventSchema = z.discriminatedUnion('aggregateType', [
z.discriminatedUnion('type', [
baseEventSchema.extend({
aggregateType: z.literal('ingredients'),
type: z.literal('IMPORTED_INGREDIENT'),
payload: z.object({ id: z.string(), name: z.string() }),
}),
baseEventSchema.extend({
prevId: z.string().uuid(),
aggregateType: z.literal('ingredients'),
type: z.literal('UPDATED_INGREDIENT'),
payload: z.object({ name: z.string() }),
}),
]),
z.discriminatedUnion('type', [
baseEventSchema.extend({
aggregateType: z.literal('categories'),
type: z.literal('IMPORTED_CATEGORY'),
payload: z.object({ id: z.string(), name: z.string() }),
}),
baseEventSchema.extend({
prevId: z.string().uuid(),
aggregateType: z.literal('categories'),
type: z.literal('UPDATED_CATEGORY'),
payload: z.object({ name: z.string() }),
}),
]),
]);
I am trying to achieve a similar nested Discriminated Union and it seems this PR solves that issue as well. Any idea when it might get merged, any way to help?
+1, I have a similar use case here ✋🏻 and would love to make use of this enhancement.
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.
not stale
How about this solutions? Honestly - I have just tried zod for like a couple of hours, but I immediately stumped on this issue.
import { z } from "zod";
const nestedDU = z.discriminatedUnion("type", [
z.object({
type: z.literal("foo"),
}),
z.object({
type: z.literal("bar"),
}),
...z.discriminatedUnion("subtype", [
z.object({
type: z.literal("baz"),
subtype: z.literal("able"),
}),
z.object({
type: z.literal("baz"),
subtype: z.literal("baker"),
}),
]).options,
]);
type nestedDUType = z.infer<typeof nestedDU>;
const arr: nestedDUType[] = [
{
type: "foo",
},
{
type: "bar",
},
{
type: "baz",
subtype: "able",
},
{
type: "baz",
subtype: "able",
},
];
In my actual project where I have TS 4.8, this did not produce the correct results (subtype remained optional). But with TS 5.0.4 it did create correct type, and seemed to work. The key - get the source objects back from discriminatedUnion with .options
and adding all of them to the original type by spreading them.
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.
not stale 🤯