Z
Zod2w ago
Tango

Tango - I have the need to handle discriminated...

I have the need to handle discriminatedUnion and enums with an "Unkown" option. Is there a way to do something like that in a native way?
const Cat = z.object({ type: z.literal("Cat"), mood: z.string() });
const Dog = z.object({ type: z.literal("Dog") });
const UnknownPet = z.object({ type: z.string() });

const Pet = z.discriminatedUnion([Cat, Dog, UnknownPet]);
// ?^ ❌`Error: Invalid discriminated union option at index "2"`
const Cat = z.object({ type: z.literal("Cat"), mood: z.string() });
const Dog = z.object({ type: z.literal("Dog") });
const UnknownPet = z.object({ type: z.string() });

const Pet = z.discriminatedUnion([Cat, Dog, UnknownPet]);
// ?^ ❌`Error: Invalid discriminated union option at index "2"`
I wrote a bit about the problem I like to solve in more detail here: the_problem_of_typesafe_parsing_the_unknown_with_zod
Solution:
Thats fine, I guess either way, we need a bunch of dynamic code, if we like it to behave as desired. I think within code generation we have some options, but nothing really streight forward. Thanks again....
Jump to solution
17 Replies
Scott Trinh
Scott Trinh6d ago
Discriminated unions in TypeScript don't work that way, so discriminated unions in Zod don't either. 😅 I know this seems like a flippant reply, but it's the only honest thing. You can use union and just deal with the fact that TypeScript cannot narrow since this isn't a discriminated union, or you can have a multi-layered approach, which is what I typically do. Basically parse the data with the most strict schema, and if it passes, great! You now have the known, useful type. If it fails, try parsing it with a more lenient schema (like UnknownPet) in your example. Now you're forced by the compiler to deal with this loose type. What should you do in this case? Well, that is up to your logic: maybe this is a webhook handler that should ignore unknown types: log it and continue. Maybe you need to throw a more useful error? We can't know, but keeping to this strict parsing means you are dealing with this ambiguity here rather than everywhere you pass the data downstream of this boundary.
Tango
TangoOP6d ago
Discriminated unions in TypeScript don't work that way, so discriminated unions in Zod don't either. 😅
I actually agree ^^.
What should you do in this case? Well, that is up to your logic
Exactly. Working with Zod on API / Integration layers, we need be able to let the application layer deal with the unknown. However, there is also the need to parse as strict as possible with well defined error messages. Currently I try to solve the issue with a z.custom logic. It is a bigger example, but I explan it with a little more detail here: https://github.com/dasaplan/ts-mono/blob/main/packages/openapi-tolerant-reader/docs/a_solution_for_typesafe_parsing_the_unknown.md#solution-creating-a-custom-zod-schema
const PetMatcher = {Cat: Cat, Dog: Dog, onDefault: UnknownPet} as const;
const Pet = createTolerantPetSchema("type", PetMatcher);

type TPetMatcher = typeof PetMatcher;

export function createTolerantPetSchema(discriminator: "type", matcher: TPetMatcher) {
return z.custom((val) => {
if (typeof val !== "object" || val === null) {
// invalid payload
return false;
}
if (!(discriminator in val) || typeof val[discriminator] !== "string") {
// invalid payload, invalid discriminator
return false;
}
const discriminatorValue = val[discriminator];
const schema = discriminatorValue in matcher ? matcher[discriminatorValue as keyof TPetMatcher] : matcher["onDefault"];
const parsed = schema.safeParse(val);
return parsed.success ? parsed.data : false;
});
const PetMatcher = {Cat: Cat, Dog: Dog, onDefault: UnknownPet} as const;
const Pet = createTolerantPetSchema("type", PetMatcher);

type TPetMatcher = typeof PetMatcher;

export function createTolerantPetSchema(discriminator: "type", matcher: TPetMatcher) {
return z.custom((val) => {
if (typeof val !== "object" || val === null) {
// invalid payload
return false;
}
if (!(discriminator in val) || typeof val[discriminator] !== "string") {
// invalid payload, invalid discriminator
return false;
}
const discriminatorValue = val[discriminator];
const schema = discriminatorValue in matcher ? matcher[discriminatorValue as keyof TPetMatcher] : matcher["onDefault"];
const parsed = schema.safeParse(val);
return parsed.success ? parsed.data : false;
});
Scott Trinh
Scott Trinh6d ago
declare const data: unknown;

const knownData = KnownData.safeParse(data);
if (knownData.success) {
// knownData is a nice discriminated union. Sweet! Do your stuff
return;
}

const unknownData = UnknownData.safeParse(data);
if (unknownData.success) {
// unknownData is parseable as _something_ so do something with that here
return;
}

throw unknownData.error;
declare const data: unknown;

const knownData = KnownData.safeParse(data);
if (knownData.success) {
// knownData is a nice discriminated union. Sweet! Do your stuff
return;
}

const unknownData = UnknownData.safeParse(data);
if (unknownData.success) {
// unknownData is parseable as _something_ so do something with that here
return;
}

throw unknownData.error;
FWIW, I think your "tolerant" schema here is no better than union
Tango
TangoOP6d ago
yeah, error handling was something which I also thought of.
Scott Trinh
Scott Trinh6d ago
well, I guess it could be a little better than union at the moment since it short-circuits for the known types. but at the type level you still get a non-discriminated union.
Tango
TangoOP6d ago
yep, this is also an issue - with some intersection magic or tagged / opaque typing however, to me at least somewhat solvable
Scott Trinh
Scott Trinh6d ago
Another more principled approach is to make a known sentinel schema to "catch" these cases and transform them into the discrimination. That makes it at least somewhat easier to pass downstream.
Tango
TangoOP6d ago
interesting
Scott Trinh
Scott Trinh6d ago
Basically make a transform that maps it into your final discriminated type and just black boxes the data.
Tango
TangoOP6d ago
wouldn't a discrimnatedUnion already have blocked any unknown option?
Scott Trinh
Scott Trinh6d ago
yeah, you cannot use the discriminatedUnion schema type here, you have to use union. Maybe you can mix it with .or
const Pet = z.discriminatedUnion("type", [Cat, Dog]).or(UnknownPet);
const Pet = z.discriminatedUnion("type", [Cat, Dog]).or(UnknownPet);
Works fine too, and gives you a slight boost if your union contains many members
Tango
TangoOP6d ago
describe("zod", () => {
test("should fail for invalid known schema", () => {
const Pet = z.union([Cat, Dog, UnknownPet]);
const result = Pet.safeParse({ type: "Cat" });
expect(result.success).toEqual(false);
});
});
describe("zod", () => {
test("should fail for invalid known schema", () => {
const Pet = z.union([Cat, Dog, UnknownPet]);
const result = Pet.safeParse({ type: "Cat" });
expect(result.success).toEqual(false);
});
});
test("should fail for invalid known schema - discriminated Union", () => {
const Pet = z.discriminatedUnion("type", [Cat, Dog]).or(UnknownPet);
const result = Pet.safeParse({ type: "Cat" });
expect(result.success).toEqual(false);
});
test("should fail for invalid known schema - discriminated Union", () => {
const Pet = z.discriminatedUnion("type", [Cat, Dog]).or(UnknownPet);
const result = Pet.safeParse({ type: "Cat" });
expect(result.success).toEqual(false);
});
unfortunately, this is not good enough. I had similar thoughts. It will pass also for schemas, we know are incorrect.
const UnknownPet = z
.object({ type: z.string() })
.passthrough()
.transform((v) => ({
type: "UnknownPet" as const,
object: v as unknown,
}));
const UnknownPet = z
.object({ type: z.string() })
.passthrough()
.transform((v) => ({
type: "UnknownPet" as const,
object: v as unknown,
}));
From your example, you would rather try to transform to a value we know at compile time. I think, this is also the solution for several client / server code generators for their deserializers. But all obviously need some "engine" or transformation logic to work the unknown type out. For Javascript I heaven't really seen anything yet.
Scott Trinh
Scott Trinh6d ago
That seems correct to me since it's not discriminated. I'm not sure I follow this question/comment. What's the specific issue with this kind of blackboxed approach?
Tango
TangoOP6d ago
oh, sry, nothing really. I just can't get it working like I would need it. yeah. Maybe this is not something zod can / should solve. Could be very specific for "deserialization". Thanks for your insights!
Scott Trinh
Scott Trinh6d ago
Basically, you want { type: "Cat" } to fail, but there is no way to reasonably do that statically without a bunch of dynamic code. In my personal opinion, I would embrace that and treat it the same as { type: "Spaceship", origin: "Alderaan" } "These are values that are a superset of a known shape, but a completely opaque unknown properties". And whatever you can reasonably do with that seems fine. Including going back and checking if they had a type from the known union and complaining. I guess you could tweak my original "two phase parse" suggestion and examine the error from the strict parsing to see if it was { type: "Cat" }-like.
Solution
Tango
Tango6d ago
Thats fine, I guess either way, we need a bunch of dynamic code, if we like it to behave as desired. I think within code generation we have some options, but nothing really streight forward. Thanks again.

Did you find this page helpful?