DBスキーマファーストな Prisma の型定義に対する zod の使い方 と Fragment colocation ライクな実装

どうも、アドカレの時期だけ記事を投稿するマンです🧘

去年に引き続き今年もこれと言ったネタがありませんが、自分の学んだことをつらつらと書きます。

（ちなみに）
去年のアドカレ記事でご紹介したライブラリ `Lenis` は既にメジャーバージョンがリリースされており Weekly Downloads も 10K超え（2023/11/23 - 2023/11/29）と、かなりプロダクション環境にも使えるようになっていそうです👀（詳しくは以下の記事をご覧ください）

https://qiita.com/ki-tamago/items/0f5d594077fd010d9b21


## はじめに

REST API のような既存の型情報がない場合は、zod のスキーマを正とした実装が可能です。それに対して Prisma は DB ソリューションと型生成が一つになっています。

そのため Prisma のスキーマ を Single Source Of Truth とする設計に対して、**Prisma スキーマから生成される型情報に沿っている事を担保する** zod の schema を実装します。

（これだけではあまりにも内容が薄すぎたので、Fragment colocation ライクな実装という別トピックも記載しましたw）

## 結論

[TypeScript4.9 から追加](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator)された `satisfies` 演算子を使う事で、**型の Widening を抑えつつ簡単に zod のスキーマが既存型情報に沿っていることを担保 & コロケーションが実現**できます。

```ts:schema.prisma
model User {
  id String @id @default(uuid())
  name String
  imageUrl String @db.Text
  email String @db.Text
  isPremium Boolean @default(false)
  servers Server[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
```

上記の prisma のスキーマがある場合における `User` に対して新規レコードを作成する場合のzodスキーマは、以下のように実装できます。

```ts:sample.ts
import { z } from 'zod';
import { Prisma } from '@prisma/client';

// ✅
const UserCreateSchema = z.object({
  name: z.string(),
  imageUrl: z.string(),
  email: z.string(),
  isPremium: z.boolean().optional(),
}) satisfies z.ZodType<Prisma.UserCreateInput>;

// ❌
const UserCreateSchema = z.object({
  name: z.string(),
  imageUrl: z.string(),
  email: z.number(), // error : 型 'number' を型 'string' に割り当てることはできません。
  isPremium: z.boolean().optional(),
}) satisfies z.ZodType<Prisma.UserCreateInput>;
```

ご存知の方は多いかと思いますが、ここで出てきている`UserCreateInput`という型は、Prisma Client のヘルパー型を使用して動的に生成されている型情報になります。

### Prisma Client のヘルパー型 とは？

`npx prisma generate`を実行した際に生成されるデータモデルの型は、Prisma Client のヘルパー型を用いて動的に生成されています。そして型変数名は `MODAL_NAME` にはスキーマに定義したデータモデル名が割り当てられるようになっています。

```ts:node_modules/.prisma/client/index.d.ts
- Prisma.<MODAL_NAME>DefaultArgs
- Prisma.<MODAL_NAME>GetPayload
- Prisma.<MODAL_NAME>Select
- Prisma.<MODAL_NAME>CountAggregateInputType

// データモデル名が User の場合
- Prisma.UserDefaultArgs
- Prisma.UserGetPayload
- Prisma.UserSelect
- Prisma.UserCountAggregateInputType
```

https://www.prisma.io/docs/reference/api-reference/prisma-client-reference

## Fragment colocationとは

**コロケーション（colocate） は 文字通り、 co(一緒に) + locate(配置する) という意味**であり、コンポーネント内で必要なデータをコンポーネントと同じ場所で宣言する手法です。

GraphQL の文脈では、コンポーネントが必要なデータを GraphQL の `Fragment` で記述し、コンポーネント内で宣言する事を指します。

Fragment colocation を使うことで、親コンポーネントは、子コンポーネントそれぞれで宣言された `Fragment` を取りまとめて、単一クエリを投げるという形を取ります。

そうすることで、親コンポーネントは子コンポーネントが子コンポーネントで宣言されたフラグメントだけを知っていれば良いため、どのようなフィールドが必要かを知る必要がなくなります。

要するに、 **コンポーネントが必要とするデータ（クエリ）はそのコンポーネント内で管理しようぜ**という事です。

https://graphql.org/learn/queries/#fragments

## Fragment colocation ライクな使い方をしてみる

Prisma Client のヘルパー型 と `satisfies` 演算子を用いて、Next.js（App Router） で以下の3つのコンポーネントを Fragment colocation ライクに実装してみます。

- ログインユーザーの詳細一覧を表示するページ （`UserDetail.tsx`） ← **親コンポーネント**
- ログインユーザーが参加しているサーバー情報一覧を表示するコンポーネント （`ServerDetail.tsx`）
- ログインユーザーがフォローしているメンバーを表示するコンポーネント （`MemberDetail.tsx`）

なお、今回使用したライブラリのバージョンは以下の通りです。

"next": "13.5.6",
"typescript": "5.3.2",
"prisma": "^5.6.0",
"@prisma/client": "^5.6.0",


```tsx:MemberDetail.tsx
// ログインユーザーがフォローしているメンバーを表示するコンポーネント

import { Prisma } from "@prisma/client";

export const MemberDetailFragment = {
  members: {
    select: {
      id: true,
      role: true,
      createdAt: true,
      updatedAt: true,
      profile: true,
    },
  },
} satisfies Prisma.UserSelect;

type MemberDetailFragment = Prisma.UserGetPayload<{
  select: typeof MemberDetailFragment;
}>;

const MemberDetail = ({ members }: { members: MemberDetailFragment }) => {
  return (
    <div>
      <h2>Member Detail</h2>
      <ul>
        {members.members.map((member) => (
          <li key={member.id}>{member.profile.name}</li>
        ))}
      </ul>
    </div>
  );
};
```

```tsx:ServerDetail.tsx
// ログインユーザーが参加しているサーバーを表示するコンポーネント

import { Prisma } from "@prisma/client";

export const ServerDetailFragment = {
  servers: true,
} satisfies Prisma.UserSelect;

type ServerDetailFragment = Prisma.UserGetPayload<{
  select: typeof ServerDetailFragment;
}>;

const ServerDetail = ({ servers }: { servers: ServerDetailFragment }) => {
  return (
    <div>
      <h2>Server Detail</h2>
      <ul>
        {servers.servers.map((server) => (
          <li key={server.id}>{server.name}</li>
        ))}
      </ul>
    </div>
  );
};
```

```tsx:UserDetail.tsx
// ログインユーザーの詳細ページ （親コンポーネント）

import { db } from "@/lib/db";
import { Prisma } from "@prisma/client";

import { ServerDetail } from "./ServerDetail";
import { MemberDetail } from "./MemberDetail";

import type { ServerDetailFragment } from "./ServerDetail";
import type { MemberDetailFragment } from "./MemberDetail";

export const UserDetailFragment = {
  userId: true,
  name: true,
  email: true,
  imageUrl: true,
  createdAt: true,
} satisfies Prisma.UserSelect;

export const UserDetail = async () => {

  const loginUser = await currentLoginUser();

  const user = await db.user.findUnique({
    where: {
      userId: loginUser.userId,
    },
    select: {
      ...UserDetailFragment,
      ...MemberDetailFragment,
      ...ServerDetailFragment,
    },
  });

  if (!user) return <div>No Result.</div>;

  return (
    <div>
      <div>
        <div>{user?.userId} : {user?.name}</div>
        <ServerDetail servers={user} />
        <MemberDetail members={user} />
      </div>
    </div>
  );
};
```

https://www.prisma.io/blog/satisfies-operator-ur8ys8ccq7zb

## 終わり

TypeScript4.9 にて追加された`satisfies` 演算子を使うことで、Prisma スキーマから生成される型情報に zod のスキーマが沿っている事を担保する事や、Fragment colocationライクなクエリの再利用を実装する事が出来ました。

記載している内容に何か間違いがありましたら、コメント頂ければと思います。

少しでもどなたかのお役に立てれば何よりです！

では明日のアドカレ記事にご期待ください！