Creating conditional keys using the Zod library based on the value of another key

Incorporating the TMDB API into my project, I am making an effort to enhance type safety by reinforcing some of the TypeScript concepts I am learning. To achieve this, I am utilizing Zod to define the structure of the data returned by the API.

Upon investigating, I have observed that depending on the request parameters, the API can return data with varying keys. Specifically, when the API responds with data from the "trending" endpoint where data.media_type = "movie", it includes keys such as title, original_title, and release_date. However, if data.media_type = "tv", these three keys are replaced with name, original_name, and first_air_date, along with the additional key origin_country.

Consequently, I have defined the shape of my data as follows:

const mediaType = ["all", "movie", "tv", "person"] as const

const dataShape = z.object({
    page: z.number(),
    results: z.array(z.object({
        adult: z.boolean(),
        backdrop_path: z.string(),
        first_air_date: z.string().optional(),
        release_date: z.string().optional(),
        genre_ids: z.array(z.number()),
        id: z.number(),
        media_type: z.enum(mediaType),
        name: z.string().optional(),
        title: z.string().optional(),
        origin_country: z.array(z.string()).optional(),
        original_language: z.string().default("en"),
        original_name: z.string().optional(),
        original_title: z.string().optional(),
        overview: z.string(),
        popularity: z.number(),
        poster_path: z.string(),
        vote_average: z.number(),
        vote_count: z.number()
    })),
    total_pages: z.number(),
    total_results: z.number()
})

To tackle this issue, I have applied the .optional() method to each problematic key. Nevertheless, this approach lacks robustness in terms of type safety. Is there a method to specify that the existence of the origin_country key is contingent upon the value of media_type being equal to tv, or to define the keys name and title as z.string() based on certain conditions?

I should mention that the media_type is also specified outside the retrieved data, specifically in the input to the API call (which appears as shown below, using tRPC):

import { tmdbRoute } from "../utils"
import { publicProcedure } from "../trpc"

export const getTrending = publicProcedure
    .input(z.object({
        mediaType: z.enum(mediaType).default("all"),
        timeWindow: z.enum(["day", "week"]).default("day")
    }))
    .output(dataShape)
    .query(async ({ input }) => {
        return await fetch(tmdbRoute(`/trending/${input.mediaType}/${input.timeWindow}`))
            .then(res => res.json())
    })

Your assistance would be greatly appreciated!

Edit: Subsequent to posting this, I have come across the Zod method discriminatedUnion(). However, I am encountering difficulties in implementing this method correctly. Currently, my implementation resembles the following:

const indiscriminateDataShape = z.object({
    page: z.number(),
    results: z.array(
        z.object({
            adult: z.boolean(),
            backdrop_path: z.string(),
            genre_ids: z.array(z.number()),
            id: z.number(),
            media_type: z.enum(mediaType),
            original_language: z.string().default("en"),
            overview: z.string(),
            popularity: z.number(),
            poster_path: z.string(),
            vote_average: z.number(),
            vote_count: z.number()
        })
    ),
    total_pages: z.number(),
    total_results: z.number()
})

const dataShape = z.discriminatedUnion('media_type', [
    z.object({
        media_type: z.literal("tv"),
        name: z.string(),
        first_air_date: z.string(),
        original_name: z.string(),
        origin_country: z.array(z.string())
    }).merge(indiscriminateDataShape),
    z.object({
        media_type: z.literal("movie"),
        title: z.string(),
        release_date: z.string(),
        original_title: z.string()
    }).merge(indiscriminateDataShape),
    z.object({
        media_type: z.literal("all")
    }).merge(indiscriminateDataShape),
    z.object({
        media_type: z.literal("person")
    }).merge(indiscriminateDataShape)
])

When invoking the request with any value for media_type using the aforementioned code, an error message is displayed stating "Invalid discriminator value. Expected 'tv' | 'movie' | 'all' | 'person'".

Answer №1

Utilizing Zod for schema validation is a prime example of effective programming practice. While discriminated unions offer a solution to your issue, it seems there may have been some misinterpretation of the API schema in your previous implementation.

When making requests to the TMDB API, a basic schema typically looks like this:

const schema = {
  page: 1,
  results: [],
  total_pages: 100,
  total_results: 200,
}

Therefore, when creating your Zod schema, it's essential to take this into consideration first. Following that, you can use the z.discriminatedUnion() function within the results property. Additionally, I suggest merging or extending the baseShape in the final step (after implementing discriminatedUnion).

const baseShape = z.object({
  adult: z.boolean(),
  backdrop_path: z.string(),
  genre_ids: z.array(z.number()),
  id: z.number(),
  original_language: z.string().default('en'),
  overview: z.string(),
  popularity: z.number(),
  poster_path: z.string(),
  vote_average: z.number(),
  vote_count: z.number(),
});

const resultShape = z
  .discriminatedUnion('media_type', [
    // tv shape
    z.object({
      media_type: z.literal('tv'),
      name: z.string(),
      first_air_date: z.string(),
      original_name: z.string(),
      origin_country: z.array(z.string()),
    }),

    // movie shape
    z.object({
      media_type: z.literal('movie'),
      title: z.string(),
      release_date: z.string(),
      original_title: z.string(),
    }),

    // all shape
    z.object({
      media_type: z.literal('all'),
    }),
  ])
  .and(baseShape);

const requestShape = z.object({
  page: z.number(),
  results: z.array(resultShape),
  total_pages: z.number(),
  total_results: z.number(),
});

To see the complete implementation and test data, please visit StackBlitz.

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

What is the best way to change an array element into a string in TypeScript?

Within my Angular 2 component, I am utilizing an array named fieldlist which is populated by data retrieved from an http.get request. The array is declared as follows: fieldlist: string[] = []; I populate this array by iterating through the JSON response ...

Capable of retrieving response data, however, the label remains invisible in the dropdown menu

Upon selecting a country, I expect the corresponding city from the database to be automatically displayed in the dropdown menu. While I was able to retrieve the state response (as seen in the console output), it is not appearing in the dropdown menu. Inte ...

Using TypeScript will result in errors when attempting to use the Promise object and the Awaited keyword

In this example, I am trying to ensure that the function foo does not accept a Promise as an argument, but any other type should be acceptable. export {} function foo<T>(arg: T extends Promise<unknown> ? never : T) { console.log(arg); } asy ...

Exploring the capabilities of the Next.js router and the useRouter

import { routeHandler } from "next/client"; import { useRouteNavigator } from "next/router"; const CustomComponent = () => { const routerFromHook = useRouteNavigator(); } export default CustomComponent; Can you explain the disti ...

ES6 Update: Manipulating Nested Arrays with JavaScript

I have the following list of items: [ { idItem: "1", name: "apple", itemLikes: [{ id: "1", idItem: "1" }] } ] My goal is to simply add a new object to the itemLikes array. Here is my ...

A guide on implementing directives in Angular 2

I am trying to load my navbar.html in my app.component.html by using directives and following the method below: Here is my navbar html: <p>Hi, I am a pen</p> This is my navbar.ts: import {Component, Directive, OnInit} from '@angular/c ...

What is the correct way to include a new property in the MUI Link component using TypeScript?

Currently, within my mui-theme.ts configuration file, I have the following setup: const theme = createTheme(globalTheme, { components: { MuiLink: { variants: [ { props: { hover: 'lightup' }, style: { ...

In TypeScript, Firestore withConverter may return a QueryDocumentSnapshot instead of the expected Class object

I'm currently exploring the usage of Firestore's withConverted method in Typescript to retrieve queries as instances of my customized class. Custom EventConverter Class import Event from "@/models/Event"; class EventConverter implemen ...

Is there a way to determine the quantity of lines within a div using a Vue3 watcher?

Is it feasible to determine the number of text lines in a div without line breaks? I am looking to dynamically display or hide my CTA link based on whether the text is less than or equal to the -webkit-line-clamp value: SCRIPT: const isExpanded = ref(true ...

Set up a custom key combination to easily toggle between HTML and TypeScript files that share the same name

Is it possible to set up a keyboard shortcut (e.g. Ctrl + `) to toggle between mypage.html and mypage.ts files? In my project, I have one HTML file and one TypeScript (TS) file with the same names. Ideally, I'd like to create a hotkey similar to F7 fo ...

Exploring Heroes in Angular 2: Retrieving Object Information by Clicking on <li> Items

Currently, I am delving into the documentation for an angular 4 project called "Tour of Heroes" which can be found at https://angular.io/docs/ts/latest/tutorial/toh-pt2.html. <li *ngFor="let hero of heroes" (click)="onSelect(hero)">{{hero.name}}< ...

In AngularJS, the $http get method is displaying the status code of the data object instead of

After pondering over this issue for several days, I am still unable to pinpoint what I am doing wrong. Any suggestions or insights would be greatly appreciated. My challenge lies in trying to showcase the response from a rest service to the user by utilizi ...

Function arity-based type guard

Consider a scenario where there is a function with multiple optional parameters. Why does the function's arity not have a type guard based on the arguments keyword and what are some solutions that do not require altering the implementation or resorti ...

A guide to adding a delay in the RxJS retry function

I am currently implementing the retry function with a delay in my code. My expectation is that the function will be called after a 1000ms delay, but for some reason it's not happening. Can anyone help me identify the error here? When I check the conso ...

Error encountered: The input value does not correspond to any valid input type for the specified field in Prisma -Seed

When trying to run the seed command tsx prisma/seed.ts, it failed to create a post and returned an error. → 6 await prisma.habit.create( Validation failed for the query: Unable to match input value to any allowed input type for the field. Parse erro ...

Using the pipe operator in RXJS to transform an Event into a KeyboardEvent

I'm encountering an error when trying to add a keydown event and convert the parameter type from Event to KeyboardEvent as shown below. fromEvent(document, "keydown") .pipe<KeyboardEvent, KeyboardEvent>( filter((event) => even ...

Generate dynamic rows with auto-generated IDs on click event in Angular

Can anyone assist me in dynamically adding rows and automatically generating IDs? I am currently using a click event for this task, but when adding a row, I have to input details manually. Is there a way to automate this process? Any help would be greatly ...

Using private members to create getter and setter in TypeScript

Recently, I developed a unique auto getter and setter in JavaScript which you can view here. However, I am currently unsure of how to implement this functionality in TypeScript. I am interested in creating an Object Oriented version of this feature if it ...

Stop the direct importing of modules in Angular applications

I have a feature module that declares components and also configures routing through a static function. @NgModule({ declarations: FEATURE_COMPONENTS, entryComponents: FEATURE_COMPONENTS, imports: [ UIRouterModule, ... ] }) export class Fea ...

What is the best way to create an assertion function for validating a discriminated union type in my code?

I have a union type with discriminated properties: type Status = { tag: "Active", /* other props */ } | { tag: "Inactive", /* other props */ } Currently, I need to execute certain code only when in a specific state: // At some po ...