Zod Nested Discriminated Unions (Tutorial)

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.

Zod Nested Discriminated Unions

If you're just looking for a solution, here it is from the Zod documentation:

zod.ts
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.

schemas/shape.ts
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

Zod Multiple Nested Discriminators

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:

types/shape.ts
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, 
}

Nested Discriminated Unions in Typescript

Here's how we'd achieve the same nested discriminated union types in pure Typescript:

types/shape.ts
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;

Wrapping Up

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.

Ryan Chiang

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:

2024

2023

© 2023 Ryan Chiang|ryanschiang.com