株式会社ファストコーディングのフルスタックエンジニア 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/resolvers | React 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駆動開発のご相談を承っています。お困りごとがあれば、お問い合わせフォームからお気軽にご連絡ください。

