DEV Community

Cover image for Infer Astro Zod Content Schema in TSX React Components avoiding Code Duplication
Ingo Steinke, web developer
Ingo Steinke, web developer Subscriber

Posted on • Edited on

Infer Astro Zod Content Schema in TSX React Components avoiding Code Duplication

Astro, the meta-framework JAMStack and Next alternative thanks to its "islands architecture", recommends to use Zod to define a data schema. So far, so good. It works well in Astro components, and it works in React when I define the same schema redundantly.

Screenshot from the file Books.tsx linked above and quoted below

Zod:

// content.config.ts (Zod)
icon: z.enum(['book', 'blogpost']).default('book').optional(),
Enter fullscreen mode Exit fullscreen mode

React:

// Book.tsx (TypeScript JSX)
interface CardProps {
  icon?: 'book'|'blogpost';
Enter fullscreen mode Exit fullscreen mode

Surely there must be a way to infer the interface from Zod, but I didn't find it in the documentation. Claude and Perplexity claim that it works and that it's documented, but fail to point to an authoritative documentation link for that specific use case, and they turn around in circles suggesting variations of hallucinated code that never completely works before I run out of tokens.

Claude failure screenshot

Solutions might include splitting my Zod definition into a schema and a collection, like below. I use the simple title string field here for the sake of readability.

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

export const bookSchema = z.object({
  schema: z.object({
    title: z.string(),
  }),
});

const bookCollection = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/books' }),
  schema: bookSchema, // Your schema goes here
});

// noinspection JSUnusedGlobalSymbols
export const collections = { book: bookCollection };
Enter fullscreen mode Exit fullscreen mode

Then infer and use it in React, merging Astro's implicitly generated id value somehow similar to this:

import React from 'react';
import type { CollectionEntry } from 'astro:content'; // or from 'zod';
import type { bookSchema } from '../content.config';

// This automatically infers the schema type - NO duplication!
export type BookProps = bookSchema & { id: string };

const Book = (props: BookProps) => {
  return (
    <article>
      <h3>{props.title}</h3>
Enter fullscreen mode Exit fullscreen mode

or, even simpler, stick with my bookCollection and use that in React:

import type { z } from 'astro:content';
import { collections } from '../content.config';

type BookProps = z.infer<typeof collections.book.schema> & { id: string };

const Book = (props: BookProps) => {
  return <article>{props.title}</article>;
};
Enter fullscreen mode Exit fullscreen mode

None of this avoided the TypeScript error TS2339: Property title does not exist on type BookProps.

Pragmatically, I reverted my attempt at refactoring and define my data redundantly until I have a better solution. It must be right in front of my eyes in Astro's content collection schema documentation, but I didn't see it. 🤔❓

Conclusion: Solution

Considering Kamran Khalid's answer on StackOverflow in my Astro and Tailwind context:

What's different in the working code?

import { z } from 'astro:content'; // not type { CollectionEntry }
import { bookSchema } from '../content.config'; // not import type

type BookProps = z.infer<typeof bookSchema> & { id: string };
Enter fullscreen mode Exit fullscreen mode

It does not matter if we import z from zod or astro:content.
It does not matter if we add Astro's auto-generated id in TSX or as an optional value in the content config, but the latter reduces repetitive boilerplate code in the TSX components.

export const bookSchema = z.object({
  // Astro generates a unique id for every item automatically
  id: z.string().optional(),
Enter fullscreen mode Exit fullscreen mode

and just

import { z } from 'astro:content';
import { bookSchema } from '../content.config';

type BookProps = z.infer<typeof bookSchema>;
Enter fullscreen mode Exit fullscreen mode

The explicit bookSchema import ensures that the Tailwind compiler still finds the class names that we define, now only once, in content.config.ts.

Voilà! No more code repetition!

Top comments (1)

Collapse
 
ingosteinke profile image
Ingo Steinke, web developer • Edited

What did the automated guesses by Claude and Perplexity miss, that the StackOverflow answer got right? Don't import type and don't randomly change unrelated lines when refining suggested code. 😂 I updated my code and my post, now avoiding code duplication.