ymmr
Recent Articles
    公開日: 2023/12/02

    Prisma、Zod、React Hook Form で繋ぐ型のリレー

    Prisma が生成した型をフォームバリデーションに利用したいと考え、Zod と React Hook Form にたどり着きました。DB からフォームまで、一貫した型安全を実現する方法を紹介します。

    Prisma から Zod

    Prisma はスキーマファイルで定義したモデルから、その型を生成してくれます。

    model User {
      id   Int    @id @default(autoincrement())
      name String
    }
    
    import type { Prisma } from '@prisma/client';
    import type { User } from '@prisma/client';
    
    // User モデルの型
    const user: User = {
      id: 1,
      name: 'Bob',
    };
    
    // User モデルをつくるのに必要なオブジェクトの型 (id は自動採番されるため取り除かれている)
    const userCreateInput: Prisma.UserCreateInput = {
      name: 'Alice',
    };
    

    Zod はスキーマ宣言とバリデーションのためのライブラリです。
    基本的には以下のような使い方をします。

    const user = {
      id: 1,
      name: 'Bob',
    };
    
    // スキーマ宣言
    const User = z.object({
      id: z.number(),
      name: z.string(),
    });
    
    // スキーマバリデーション
    if (User.safeParse(user)) {
      // user は User スキーマの条件を満たすことを保証される
      console.log(`Hello, ${user.name}!`);
    }
    
    // スキーマの型を参照
    type UserSchema = z.infer<typeof userSchema>;
    

    Zod ファーストでスキーマ宣言することを前提としているのか、既に定義された型に対して Zod スキーマをつくる機能は提供されていません。Prisma と組み合わせる場合、データモデルを先に考えるため、Prisma によって生成された型を Zod へ繋ぐことになります。調べたところ Zod の製作者がユーティリティ関数を公開していました。

    import type { z } from 'zod';
    
    export const schemaForType =
      <T>() =>
      <S extends z.ZodType<T, any, any>>(arg: S) => {
        return arg;
      };
    

    Typecheck schemas against existing types · Issue #372 · colinhacks/zodTL;DR I would love to have a way to ensure that a Zod schema matches an existing type definition using the normal Typescript type checker. Below is an example of the desired functionality using one...github.com

    これを使うことで Zod スキーマを宣言するときに、既存の型と生合成が取れていることを保証できます。

    const userSchema = schemaForType<User>()(
      z.object({
        id: z.number(),
        name: z.string();
      }),
    );
    

    コールバック関数の引数に渡した Zod スキーマが、Generic Parameter として渡した User 型から逸脱しているとエラーになります。次のエラーは Zod スキーマが name プロパティを持っていないことを警告しています。

    Zod から React Hook Form

    React Hook Form はフォームバリデーションのためのライブラリです。Zod のようなバリデーション機能を持つライブラリと組み合わせて使うことができます。方法はとても簡単で、インポートした resolver を useForm の引数であるオプションとして渡すだけです。

    ユーザーを登録する簡単なフォームを作成してみましょう。

    import { schemaForType } from '@/validation';
    // Zod 用の resolver をインポート
    import { zodResolver } from '@hookform/resolvers/zod';
    import { useForm } from 'react-hook-form';
    import { z } from 'zod';
    
    import type { Prisma } from '@prisma/client';
    import type { SubmitHandler } from 'react-hook-form';
    
    // Prisma によって生成された型から Zod スキーマを宣言
    const userSchema = schemaForType<Prisma.UserCreateInput>()(
      z.object({
        name: z.string(),
      }),
    );
    
    // Zod スキーマから型を参照
    type UserSchema = z.infer<typeof userSchema>;  // { name: string }
    
    const UserForm = () => {
      const {
        handleSubmit,
        register,
        formState: { errors },
        // useForm にフィールドの型が { name: string } であることを伝える
      } = useForm<UserSchema>({
        // オプションとして zodResolver を渡す
        resolver: zodResolver(userSchema),
      });
    
      const onSubmit: SubmitHandler<UserSchema> = (data) => console.log(data);
    
      return (
        <form onSubmit={handleSubmit(onSubmit)}>
          <label>
            Name:
            <input type="text" {...register('name')} />
          </label>
          {errors.name && <div role="alert">{errors.name.message}</div>}
        </form>
      );
    };
    
    export default UserForm;
    

    動作検証

    User モデルに変更が入ったときの挙動を検証します。
    スキーマファイル内のモデルにフィールドを追加して、変更を Prisma に伝えます。

     model User {
       id    Int    @id @default(autoincrement())
       name  String
    +  email String
     }
    
    npx prisma db push
    

    フォームコンポーネントを見ると、しっかりエラーになってくれています!

    しかし、型レベルではフォームに email フィールドを追加するところまでを強要できないようです。

    おわりに

    今回はデータモデルの型をフォームまで電波させる方法を紹介しました。本当は Prisma の型から Zod スキーマを自動生成するようなライブラリを使おうと考えていたのですが、メンテナンスが止まることのリスクを加味してユーティリティ関数の方を採用しました。若干の 2 重管理感は否めませんが、型の整合性をとることが目的なのでこれで満足しています。