AI駆動開発
投稿日:

React Hook Form + Zod のバリデーションをAI駆動で実装する ─ AIが得意な部分と人間が判断すべき部分

株式会社ファストコーディングのフルスタックエンジニア MrFireです。

先日、バイクの車検をオンラインで予約しようとしたら、フォームの入力項目が20個以上ありました。しかも途中で「車検証の有効期限を入力してください」と言われ、手元に車検証がなくて断念。フォームの設計って、作る側も使う側も大変です。

フォーム実装の依頼が来るたびに「またバリデーションか…」と思うのは私だけではないはず。

最近の案件で、問い合わせフォームのバリデーションをAI駆動で実装しました。React Hook Form + Zod という組み合わせで、AIにスキーマ定義からフォームコンポーネントまで一気に書かせてみたところ、基本的な型バリデーションは正確に出てくる一方、ビジネスロジック固有のバリデーションではAIの出力をかなり修正する必要がありました。

今回は、AIが得意な部分と人間の判断が必要な部分を、Before/Afterのコード比較で整理します。

React Hook Form + Zod の基本構成

まず前提として、React Hook Form と Zod の役割を整理します。

ライブラリ役割
React Hook Formフォームの状態管理、バリデーションの実行タイミング制御、パフォーマンス最適化
Zodバリデーションルールの定義(スキーマ)、型推論
@hookform/resolversReact Hook Form と Zod を接続するアダプター

この組み合わせの利点は、Zodスキーマを定義すればTypeScriptの型とバリデーションルールが一箇所にまとまることです。AIにとっても、スキーマという明確な構造があるおかげで正確なコードを出しやすい領域です。

AIが得意な部分:基本スキーマの生成

まず、AIに「問い合わせフォームのZodスキーマを書いて」と依頼した結果です。

import { z } from 'zod';

export const contactSchema = z.object({
  companyName: z
    .string()
    .min(1, '会社名を入力してください'),
  name: z
    .string()
    .min(1, 'お名前を入力してください'),
  email: z
    .string()
    .min(1, 'メールアドレスを入力してください')
    .email('メールアドレスの形式が正しくありません'),
  phone: z
    .string()
    .regex(/^0\d{9,10}$/, '電話番号の形式が正しくありません')
    .optional()
    .or(z.literal('')),
  inquiryType: z
    .enum(['見積もり依頼', 'サービスについて', '採用について', 'その他'], {
      errorMap: () => ({ message: 'お問い合わせ種別を選択してください' }),
    }),
  message: z
    .string()
    .min(10, 'お問い合わせ内容は10文字以上で入力してください')
    .max(1000, 'お問い合わせ内容は1000文字以内で入力してください'),
});

export type ContactFormData = z.infer<typeof contactSchema>;

これは正確です。基本的な型チェック(string、email形式、正規表現、enum、min/max)はAIが得意とする領域で、ほぼ修正なしで使えました。z.inferによる型推論も正しい。

AIが苦手な部分:ビジネスロジックのバリデーション

問題はここからです。実際の案件では以下のような要件がありました。

  • 「お問い合わせ種別」が「見積もり依頼」の場合は「電話番号」を必須にする
  • 「お問い合わせ種別」が「採用について」の場合は「会社名」を任意にする
  • メールアドレスの確認入力欄を追加し、一致チェックを行う

これらをAIに追加で依頼したところ、以下のコードが返ってきました。

Bad Case:AIが出した条件付きバリデーション

// AIが出した条件付き必須のコード(問題あり)
export const contactSchema = z.object({
  companyName: z
    .string()
    .min(1, '会社名を入力してください'),
  name: z
    .string()
    .min(1, 'お名前を入力してください'),
  email: z
    .string()
    .email('メールアドレスの形式が正しくありません'),
  emailConfirm: z
    .string()
    .email('確認用メールアドレスの形式が正しくありません'),
  phone: z
    .string()
    .regex(/^0\d{9,10}$/, '電話番号の形式が正しくありません')
    .optional(),
  inquiryType: z
    .enum(['見積もり依頼', 'サービスについて', '採用について', 'その他']),
  message: z
    .string()
    .min(10, 'お問い合わせ内容は10文字以上で入力してください'),
}).refine((data) => data.email === data.emailConfirm, {
  message: 'メールアドレスが一致しません',
  path: ['emailConfirm'],
}).refine((data) => {
  if (data.inquiryType === '見積もり依頼') {
    return data.phone !== undefined && data.phone !== '';
  }
  return true;
}, {
  message: '見積もり依頼の場合は電話番号が必須です',
  path: ['phone'],
});

一見動きそうですが、3つの問題があります。

問題1:.refine()の連鎖では1つのrefineにつき1つのエラーしか出せない

.refine()は1回の呼び出しで1つのエラーしか生成できません。複数の相関バリデーションを.refine()の連鎖で書くと、それぞれが独立したZodEffectsラッパーになり、コードの見通しが悪くなります。また、1つの.refine()で複数のフィールドに同時にエラーを付けることができないため、「メールアドレスの不一致」と「電話番号の必須チェック」を1つのバリデーション関数内でまとめて処理できません。

問題2:「採用について」のときに会社名を任意にするロジックがない

AIの出力にはこの要件が反映されていませんでした。プロンプトに含めたのですが、3つ目の要件として優先度が下がったのか、無視されていました。

問題3:空文字のphoneに対する.optional()の扱い

HTMLのinput要素は未入力でも空文字""を返します。Zodの.optional()undefinedを許可しますが、空文字は許可しません。AIは空文字列のケースを考慮していませんでした。

Good Case:人間が修正した最終版

import { z } from 'zod';

const phoneRegex = /^0\d{9,10}$/;

export const contactSchema = z
  .object({
    companyName: z.string().optional().default(''),
    name: z
      .string()
      .min(1, 'お名前を入力してください'),
    email: z
      .string()
      .min(1, 'メールアドレスを入力してください')
      .email('メールアドレスの形式が正しくありません'),
    emailConfirm: z
      .string()
      .min(1, '確認用メールアドレスを入力してください'),
    phone: z
      .string()
      .optional()
      .default(''),
    inquiryType: z
      .enum(['見積もり依頼', 'サービスについて', '採用について', 'その他'], {
        errorMap: () => ({ message: 'お問い合わせ種別を選択してください' }),
      }),
    message: z
      .string()
      .min(10, 'お問い合わせ内容は10文字以上で入力してください')
      .max(1000, 'お問い合わせ内容は1000文字以内で入力してください'),
  })
  .superRefine((data, ctx) => {
    // メールアドレス一致チェック
    if (data.email !== data.emailConfirm) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'メールアドレスが一致しません',
        path: ['emailConfirm'],
      });
    }

    // 見積もり依頼の場合、電話番号を必須にする
    if (data.inquiryType === '見積もり依頼') {
      if (!data.phone || data.phone === '') {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: '見積もり依頼の場合は電話番号が必須です',
          path: ['phone'],
        });
      } else if (!phoneRegex.test(data.phone)) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: '電話番号の形式が正しくありません',
          path: ['phone'],
        });
      }
    }

    // 見積もり依頼・サービスについて・その他の場合、会社名を必須にする
    if (data.inquiryType !== '採用について') {
      if (!data.companyName || data.companyName === '') {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: '会社名を入力してください',
          path: ['companyName'],
        });
      }
    }
  });

export type ContactFormData = z.infer<typeof contactSchema>;

修正のポイントは以下の3点です。

修正1:.refine()の連鎖を.superRefine()に置き換え

.superRefine()を使うと、ctx.addIssue()で複数のエラーを1つのバリデーション関数内で同時に追加できます。メールアドレスの一致チェック、電話番号の条件付き必須、会社名の条件付き必須を1箇所にまとめて書けるため、コードの見通しも良くなります。複数の相関バリデーションがある場合は.superRefine()が正解です。

修正2:会社名の条件付き必須を追加

inquiryType !== '採用について'の場合に会社名を必須にするロジックをsuperRefine内に追加しました。フィールド定義自体は.optional().default('')にしておき、条件チェックはsuperRefineに集約しています。

修正3:空文字の扱いを統一

phoneとcompanyNameに.optional().default('')を設定しています。React Hook FormのdefaultValuesで空文字を初期値にしているため、実際には.default('')が発動する場面は少ないですが、型推論上の整合性を保つ目的で付与しています。重要なのは、superRefine内で空文字チェック(!data.phone || data.phone === '')を明示的に行うことで、undefinedと空文字の両方に対応している点です。

フォームコンポーネントの実装

React Hook Formとの接続部分です。

'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { contactSchema, type ContactFormData } from './schema';

export default function ContactForm() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors, isSubmitting },
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    defaultValues: {
      companyName: '',
      name: '',
      email: '',
      emailConfirm: '',
      phone: '',
      inquiryType: undefined,
      message: '',
    },
  });

  const inquiryType = watch('inquiryType');

  const onSubmit = async (data: ContactFormData) => {
    console.log('送信データ:', data);
    // ここでAPIを呼び出す
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      {inquiryType !== '採用について' && (
        <div>
          <label htmlFor="companyName">
            会社名{inquiryType !== '採用について' && <span>*</span>}
          </label>
          <input id="companyName" {...register('companyName')} />
          {errors.companyName && (
            <p role="alert">{errors.companyName.message}</p>
          )}
        </div>
      )}

      <div>
        <label htmlFor="name">お名前 <span>*</span></label>
        <input id="name" {...register('name')} />
        {errors.name && <p role="alert">{errors.name.message}</p>}
      </div>

      <div>
        <label htmlFor="email">メールアドレス <span>*</span></label>
        <input id="email" type="email" {...register('email')} />
        {errors.email && <p role="alert">{errors.email.message}</p>}
      </div>

      <div>
        <label htmlFor="emailConfirm">メールアドレス(確認) <span>*</span></label>
        <input id="emailConfirm" type="email" {...register('emailConfirm')} />
        {errors.emailConfirm && (
          <p role="alert">{errors.emailConfirm.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="inquiryType">お問い合わせ種別 <span>*</span></label>
        <select id="inquiryType" {...register('inquiryType')}>
          <option value="">選択してください</option>
          <option value="見積もり依頼">見積もり依頼</option>
          <option value="サービスについて">サービスについて</option>
          <option value="採用について">採用について</option>
          <option value="その他">その他</option>
        </select>
        {errors.inquiryType && (
          <p role="alert">{errors.inquiryType.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="phone">
          電話番号{inquiryType === '見積もり依頼' && <span>*</span>}
        </label>
        <input id="phone" type="tel" {...register('phone')} />
        {errors.phone && <p role="alert">{errors.phone.message}</p>}
      </div>

      <div>
        <label htmlFor="message">お問い合わせ内容 <span>*</span></label>
        <textarea id="message" rows={5} {...register('message')} />
        {errors.message && <p role="alert">{errors.message.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '送信中...' : '送信する'}
      </button>
    </form>
  );
}

この部分はAIの出力がほぼそのまま使えました。register()の使い方、errorsの表示、watch()による条件付き表示などは、React Hook Formの標準パターンなのでAIが正確に書けます。

AIが得意な部分と苦手な部分のまとめ

今回の実装を通じて見えた、フォームバリデーションにおけるAIの得意・不得意を整理します。

作業AIの精度理由
Zodの基本スキーマ定義(型、min/max、regex)パターンが決まっている
React Hook Formのフォームコンポーネントregister、errors、handleSubmitは定型
TypeScriptの型推論(z.infer)構文として明確
単一フィールドのバリデーション各フィールド独立で判断できる
相関バリデーション(フィールド間の依存)ビジネスロジックの理解が必要
条件付き必須の設計「いつ必須にするか」はビジネス要件
.refine() vs .superRefine() の選択実行順序の影響をAIが予測しにくい
空文字 vs undefined の扱いHTMLの実際の挙動とZodの型の差異

パターンが決まっている定型的な部分はAIに任せ、ビジネスロジックの判断が必要な相関バリデーションは人間が設計する。この切り分けを意識するだけで、AI駆動開発の効率は大きく変わります。

まとめ

React Hook Form + Zod のバリデーション実装をAI駆動で行った結果を整理します。

  • Zodの基本スキーマ定義とReact Hook Formのコンポーネントは、AIの出力がほぼそのまま使える
  • 相関バリデーション(フィールド間の依存チェック)は、AIに任せると.refine()の連鎖になりやすい。.refine()は1回につき1エラーしか出せないため、複数フィールドへの同時エラー付与には.superRefine()が必要。この書き換え判断は人間がすべき
  • HTMLのinputが返す空文字""とZodの.optional()undefinedを許可)の挙動の差は、AIが見落としやすいポイント

フォームは「見た目」よりも「バリデーションの設計」が品質を左右します。AIが書いた基本スキーマの上に、ビジネスロジックを人間が設計して乗せる。この順番でやると、手戻りが少なく効率的です。

株式会社ファストコーディングでは、React/Next.jsのフロントエンド実装やAI駆動開発のご相談を承っています。お困りごとがあれば、お問い合わせフォームからお気軽にご連絡ください。