記事「AstroのPropsをジェネリックにして複数のコレクションを受け取る」のサムネイル

AstroのPropsをジェネリックにして複数のコレクションを受け取る

結論

方法1

CollectionEntryの型パラメーターをそのまま使います。

components/PostContent.astro
type Props<D extends keyof DataEntryMap> = {
    post: CollectionEntry<D>;
};

const { post } = Astro.props as Props<keyof DataEntryMap>;

方法2

受け取りたいコレクションの共通するフィールドで型を定義し、ジェネリックなPropsextendsを指定します。

type Document = {
    title: string;
    desc: string;
    pubDate: Date;
    updatedDate?: Date;
};

type Props<D extends Document> = {
    post: { data: D };
};

const { post } = Astro.props;

背景

ブログの記事とその他の投稿(プライバシーポリシーなど)のスキーマをそれぞれZodで定義しているのですが、どちらのコレクションも受け取れるコンポーネントを作りたいと思いました。

content.config.ts
import { defineCollection, z } from "astro:content";
import type { Document } from "@lib/types";
import { glob } from "astro/loaders";

const postsSchema = z.object({
    title: z.string(),
    desc: z.string(),
    pubDate: z.date(),
    updatedDate: z.date().optional(),
    tags: z.array(z.string()).optional(),
    pub: z.boolean().optional(),
    zenn: z.boolean().optional(),
});

const posts = defineCollection({
    loader: ...,
    schema: postsSchema,
});

const docsSchema = z.object({
    title: z.string(),
    desc: z.string(),
    pubDate: z.date(),
    updatedDate: z.date().optional(),
});
const docs = defineCollection({
    loader: ...,
    schema: docsSchema,
});

export const collections = { posts, docs };

実装

2つの方法を紹介します。方法2のほうが明示的で、メンテナンス性が高いと思います。

方法1

CollectionEntryの型パラメーターをそのまま使う方法です。通常はCollectionEntry<"コレクション名">として利用することが多いですが、"コレクション名"の部分をジェネリックにします。

components/PostContent.astro
type Props<D extends keyof DataEntryMap> = {
    post: CollectionEntry<D>;
};

const { post } = Astro.props as Props<keyof DataEntryMap>;

extends keyof DataEntryMapを指定しないとエラーになります。
as Props<keyof DataEntryMap>をつけることで、エディタの補完が効きます。post.data.と入力すると、存在するコレクションの共通するフィールドが候補として表示されます。よって、一部のコレクションにしか存在しないフィールドは補完に出ません。

方法2

受け取りたいコレクションの共通するフィールドで新しい型を定義します。

types.ts
export type Document = {
    title: string;
    desc: string;
    pubDate: Date;
    updatedDate?: Date;
};

Propsをジェネリックにして、定義した型で制約をつけます。

components/PostContent.astro
type Props<D extends Document> = {
    post: { data: D };
};

const { post } = Astro.props as Props<Document>;

これで、このコンポーネント内でDocumentを満たす型を受け取ることができます。型が一致しない場合はエラーが出ます。as Props<Document>をつけることでエディタの補完が効きます。

補完
補完

これだけでも動きますが、ZodObjectの定義時に型チェックをする方法があります。satisfies演算子を使います。

content.config.ts
const postsSchema = z.object({
    title: z.string(),
    desc: z.string(),
    pubDate: z.date(),
    updatedDate: z.date().optional(),
    tags: z.array(z.string()).optional(),
    pub: z.boolean().optional(),
    zenn: z.boolean().optional(),
}) satisfies z.ZodType<Document>;

まとめ

AstroのPropsをジェネリックにして複数のコレクションを受け取る方法を紹介しました。
CollectionEntry<"名前">では特定のコレクションしか指定できませんが、"名前"の部分をジェネリックにしたり、共通するフィールドで型を定義することで、複数のコレクションを受け取ることができます。

参考

Content Collections API Reference
Content Collections API Reference favicon docs.astro.build
Content Collections API Reference
Zodで真のTypeScript firstを手にする
Zodで真のTypeScript firstを手にする favicon zenn.dev
Zodで真のTypeScript firstを手にする
satisfies演算子「satisfies operator」 | TypeScript入門『サバイバルTypeScript』
satisfies T(Tは型)は、変数宣言時に使用する演算子で、その値が型Tを満たすことを検証します。この演算子は型の絞り込みを保持したまま型チェックを行える特徴があります。
satisfies演算子「satisfies operator」 | TypeScript入門『サバイバルTypeScript』 favicon typescriptbook.jp
satisfies演算子「satisfies operator」 | TypeScript入門『サバイバルTypeScript』