【TypeScript】react-hook-form + zodでbooleanなラジオボタンを実装する方法

zod

はじめに

こんにちは、株式会社TOKOSのツキヤです!
今回は、React(Next.js)でフォームを作る際にほぼ必須のreact-hook-formと、そのバリデーションを良い感じにカスタムできるzodのややニッチ?な説明をします!

具体的には、ラジオボタンの値をbooleanとして上手く扱う方法です!
今までラジオボタンを使う時に上手く値や型を処理できていなかった人は最後まで見て下さい💪

対象読者・前提

  • TypeScript + react-hook-form + zod を使ったことがある人
  • ラジオボタンの実装方法に困っていた方

今回は「TypeScript + react-hook-form + zod 」の大枠の実装方法の説明は省略しています🙏
また、本記事で述べているラジオボタンは<input type="radio" />のようなコードで表現するものとなっています。

ラジオボタンの問題点

ラジオボタンを実装する時に問題となるのが、選択時のvaluestringでしか扱えないことです🥲

<input type="radio" name="isAdmin" value="true" />
<label>管理者にする</label>

<input type="radio" name="isAdmin" value="false" />
<label>管理者にしない</label>

上記のようにbooleanな値を持つラジオボタンを作ろうとしても、value="true"value="false"で取得できるのは文字列の"true", "false"になってしまうので本当に欲しいbooleanを取得することができないです🫠

「TypeScript + react-hook-form + zod 」で問題の再現

上記の問題を「TypeScript + react-hook-form + zod 」で表現すると、

// スキーマの定義
const schema = z.object({
  // ここではbooleanとして定義している
  isAdmin: z.boolean({
    invalid_type_error: "booleanで入力してください!!",
  }),
});
// スキーマから型の生成
type Schema = z.infer<typeof schema>;

// コンポーネント部分
export function ZodForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<Schema>({
    resolver: zodResolver(schema),
  });

  // 送信ボタン押下後の処理
  const onSubmit = (params: Schema) => {
    console.log(params);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <div className="flex gap-x-2">
          {/* ラジオボタン */}
          <input
            {...register("isAdmin")}
            type="radio"
            id="true"
            value="true"
            name="isAdmin"
          />
          <label htmlFor="true">管理者にする</label>
        </div>
        <div className="flex gap-x-2">
          <input
            {...register("isAdmin")}
            type="radio"
            id="false"
            value="false"
            name="isAdmin"
          />
          <label htmlFor="false">管理者にしない</label>
        </div>
        {errors.isAdmin && (
          <p>{errors.isAdmin.message}</p>
        )}
      </div>
      <button type="submit">送信</button>
    </form>
  );
}
↑ 実際のUI

上記コードの4行目でisAdmin: z.booleanとしていますが、先程述べた通りラジオボタンはstringしか扱えないのでバリデーションエラーに引っかかってしまいます。

↑ チェックを入れているのにバリデーションエラーに引っかかる

ラジオボタンで上手くbooleanを扱う

スキーマ変更による修正

なので、仕方なしに上記コードのzodのスキーマは以下のように修正します

// スキーマの定義
const schema = z.object({
  // enumに変更
  isAdmin: z.enum(["true", "false"]),
});
// スキーマから型の生成
type Schema = z.infer<typeof schema>;

// 以下略

z.enum(["true", "false"])は「文字列の"true""false"のみ許容」というバリデーションです。
この記述にすることで、「チェックをしてもバリデーションエラーになる」問題は解消しました!
しかし、本来扱いたいのは真のbooleanなので、「文字列の"true""false"」を上手くbooleanに変更したいです🤔

transformによる修正

ここで、zodのtransformという関数を使います!
transformを使うことで、バリデーション後に任意の値に変換することができます✨
transformバージョンを記述します。

// スキーマの定義
const schema = z.object({
  // ここではbooleanとして定義している
  isAdmin: z.enum(["true", "false"]).transform((val: "true" | "false") => val === "true"),
});
// スキーマから型の生成
type Schema = z.infer<typeof schema>;

// 以下略

transform((val: "true" | "false") => val === "true")で、元々の値val"true"かどうかの比較した結果をreturnしています。
こうすることで、実際のboolaenに変換することができました✨

しかし、機能的には問題無いですが型定義部分がやや微妙になっています。
type Schema = z.infer<typeof schema>;で生成される型は{ isAdmin: boolean }となります。
一見問題無さそうなのですが、例えばdefaultValuesで初期値を設定しようとするとエラーになってしまいます。

↑ フォームを入力中の値は文字列の”true” or “false”なので初期値に”false”を設定しようとしているが、型エラーとなっている

この型エラーに対する問題を解消していきます!

型定義にinput, outputを使用

具体的には、まずzodのスキーマから型を生成する時に、inferの代わりにinputoutputというメソッドを使います。

// スキーマの定義
const schema = z.object({
  isAdmin: z.enum(["true", "false"]).transform((val: "true" | "false") => val === "true"),
});
// スキーマから型の生成
type InputSchema = z.input<typeof schema>;
type OutputSchema = z.output<typeof schema>;

// 以下略

inputtransformの値の型を、outputtransformの値の型を生成できます!
中身を見ると、InputSchema{ isAdmin: "true" | "false" }OutputSchema{ isAdmin: boolean }となっています。

そして、できた型をuseForm部分に適用し直します!

export function ZodForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<InputSchema, unknown, OutputSchema>({
    resolver: zodResolver(schema),
    defaultValues: {
      isAdmin: "false",
    },
  });
// 以下略

useForm<InputSchema, unknown, OutputSchema>とすることで、フォームの中の値を扱う時はInputSchemaを、バリデーション後にはOutputSchemaを使うことができます✨

よって、先程出ていたdefaultValues部分のエラーも出なくなり、onSubmitの引数もbooelanとしてisAdminの値が取得できるようになりました💪

おわりに

今回は「TypeScript + react-hook-form + zod 」でよく有りそうかつ問題になりそうなラジオボタンを題材にしてみました!
需要があれば他のinputやもっと複雑なUI・仕様のフォームの解説もしようと思います😎