Updated
—
3 min read
In Typescript, you can use discriminated unions to create "variations" of an object.
In more complex scenarios, we may want to have nested discriminated unions.
With the rising popularity of Zod as a schema validation and Typescript source of truth, I'll go over how to use nested discriminated unions in Zod.
If you're just looking for a solution, here it is from the Zod documentation:
typescriptconst FooSchema = z.discriminatedUnion("status", [
// options
]);
const BarSchema = z.discriminatedUnion("status", [
// options
]);
const FooBarSchema = z.discriminatedUnion("status", [...FooSchema.options, ...BarSchema.options]);
And here's a more practical example of using nested discriminated unions in Zod. This example also uses different discriminators when nesting discriminated unions.
typescriptconst ShapeBaseSchema = z.object({
type: z.enum(["3d", "2d"])
});
const Shape3dSchema = ShapeBaseSchema.extend({
type: z.literal("3d"),
});
const Shape2dBaseSchema = ShapeBaseSchema.extend({
type: z.literal("2d"),
shape: z.enum(["circle", "square"]) // this can be inferred from the discriminated union, but we'll define as enum for clarity
});
const CircleSchema = Shape2dBaseSchema.extend({
shape: z.literal("circle"),
radius: z.number(),
});
const SquareSchema = Shape2dBaseSchema.extend({
shape: z.literal("square"),
sideLength: z.number(),
});
// Nested discriminated union
const Shape2dSchema = z.discriminatedUnion("shape", [
CircleSchema,
SquareSchema
]);
// Parent discriminated union
const ShapeSchema = z.discriminatedUnion("type", [
Shape3DSchema,
...Shape2DSchema.options,
]);
By using the .options
property to destructure the nested discriminated union, we can merge the discriminated unions to create complex variations of objects.
Without the .options
property, we would get the error:
Type 'ZodDiscriminatedUnion<...>' is missing the following properties from type 'ZodObject<...>': _cached, _getCached, shape, strict, and 14 more
In the example above, we also have multiple nested discriminators.
We have both the type
discriminator at the higher level and the shape
discriminator nested within it.
Using these nested Zod discriminated unions, we can infer Typescript types and create objects like so:
typescripttype Shape = z.infer<typeof ShapeSchema>;
const cube: Shape = {
type: "3d",
}
const circle: Shape = {
type: "2d",
shape: "circle",
radius: 30,
}
const square: Shape = {
type: "2d",
shape: "square",
sideLength: 30,
}
Here's how we'd achieve the same nested discriminated union types in pure Typescript:
typescriptinterface ShapeBase {
type: "3d" | "2d"
}
interface Shape3d extends ShapeBase {
type: "3d";
}
interface Shape2dBase extends ShapeBase {
type: "2d";
shape: "circle" | "square"; // this can be inferred from the discriminated union, but we'll define as enum for clarity
}
interface Circle extends Shape2dBase {
shape: "circle";
radius: number;
}
interface Square extends Shape2dBase {
shape: "square";
sideLength: number;
}
// Nested discriminated union
type Shape2d = Circle | Square;
// Parent discriminated union
type Shape = Shape2d | Shape3d;
Zod is a powerful and go-to schema validator for most Typescript projects these days.
While nested discriminated unions may seem complicated, you only need to use the .options
property to merge discriminated unions.
Meet the Author
Ryan Chiang
Hello, I'm Ryan. I build things and write about them. This is my blog of my learnings, tutorials, and whatever else I feel like writing about.
See what I'm building →.
Thanks for reading! If you want a heads up when I write a new blog post, you can subscribe below: