Search code examples
typescriptzod

How to Create a Single Zod Schema for Conditional Type Validation in TypeScript?


Description

Assume we have the following set of TypeScript interfaces:

// Indexed type for type-specific fields
interface RichTextMap {
  text?: { text: string };
  equation?: { equation: string };
  mention?: { mention: string };
}

// Main RichText interface with indexed types
interface RichTextBase {
  type: "text" | "mention" | "equation";
  annotations: any;
  plain_text: string;
  href: string | null;
}

type RichText = RichTextBase & (RichTextMap[RichTextBase["type"]] & {});

The RichText type should allow the objects as follows:

const text = { 
  type: "text", 
  annotations: {},
  plain_text: "", 
  href: null, 
  text: { text: "" }
}

const mention = { 
  type: "mention", 
  annotations: {},
  plain_text: "", 
  href: null, 
  mention: { mention: "" }
}

Problem Statement

I need to define zod schema for mentioned objects.

My understanding of zod is yet to improve, but so far I've managed to come up with the following solution:

const RichTextSchema = z
  .object({
    type: z.enum(["text", "mention", "equation"]),
    annotations: z.object({}).passthrough(),
    plain_text: z.string(),
    href: z.string().url().nullable(),
});

const TextRichTextSchema = RichTextSchema.extend({
  text: z.object({ text: z.string() }).optional(),
})

const MentionRichTextSchema = RichTextSchema.extend({
  mention: z.object({ mention: z.string() }).optional(),
})

const EquationRichTextSchema = RichTextSchema.extend({
  equation: z.object({ equation: z.string() }).optional(),
})

Now, the problem with this approach is that I'm creating three different schemas and will have to figure out which one to call at runtime. While the interface type is generic enough to fit different objects under a single type.

Question: I wonder if there is a way to change the RichTextSchema to be able to validate against the aforementioned interface types. Essentially, keeping only a single schema object that validates different objects from the example.


Solution

  • What you seem to be looking for is the exact use for case for discriminated unions in Zod.

    Here is a example with literals based on your case:

    import { z } from "zod";
    
    const RichTextBaseSchema = z.object({
      annotations: z.object({}).passthrough(),
      plain_text: z.string(),
      href: z.string().url().nullable(),
    });
    
    const RichTextSchema = z.discriminatedUnion("type", [
    
      RichTextBaseSchema.extend({
        type: z.literal("text"),
        text: z.object({ 
          text: z.string() 
        }),
      }),
    
      RichTextBaseSchema.extend({
        type: z.literal("mention"),
        mention: z.object({ 
          mention: z.string() 
        }),
      }),
      
      RichTextBaseSchema.extend({
        type: z.literal("equation"),
        equation: z.object({ 
          equation: z.string() 
        }),
      }),
    ]);
    

    Or if you want to keep the enum:

    import { z } from "zod";
    
    const RichTextType = z.enum(["text", "mention", "equation"]);
    
    const RichTextBaseSchema = z.object({
      annotations: z.object({}).passthrough(),
      plain_text: z.string(),
      href: z.string().url().nullable(),
    });
    
    const RichTextSchema = z.discriminatedUnion("type", [
      RichTextBaseSchema.extend({
        type: z.literal(RichTextType.enum.text),
        text: z.object({ 
          text: z.string() 
        }),
      }),
    
      RichTextBaseSchema.extend({
        type: z.literal(RichTextType.enum.mention),
        mention: z.object({ 
          mention: z.string() 
        }),
      }),
      
      RichTextBaseSchema.extend({
        type: z.literal(RichTextType.enum.equation),
        equation: z.object({ 
          equation: z.string() 
        }),
      }),
    ]);