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.
Zod:
// content.config.ts (Zod)
icon: z.enum(['book', 'blogpost']).default('book').optional(),
React:
// Book.tsx (TypeScript JSX)
interface CardProps {
icon?: 'book'|'blogpost';
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.
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 };
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>
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>;
};
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 };
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(),
and just
import { z } from 'astro:content';
import { bookSchema } from '../content.config';
type BookProps = z.infer<typeof bookSchema>;
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)
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.