【入門】Zod で型安全なフォームバリデーションを実現する基本ステップ

現代の Web 開発において、ユーザーからの入力を安全かつ確実に処理することは欠かせません。しかし、JavaScript や TypeScript を使った従来のバリデーション手法では、ランタイムエラーや型の不整合に悩まされることが多いのが現状です。
そこで注目されているのが Zod です。Zod は、型安全なスキーマバリデーションライブラリとして、開発者の悩みを根本から解決してくれる強力なツールとなっています。
この記事では、Zod を使ったフォームバリデーションの基本から実践まで、段階的に学んでいきましょう。
背景
なぜフォームバリデーションが重要なのか
Web アプリケーションにおいて、フォームは ユーザーとシステムを繋ぐ重要な橋渡し役 を担っています。ユーザー登録、ログイン、お問い合わせフォームなど、様々な場面でユーザーからの入力を受け取る必要があります。
しかし、ユーザーの入力には以下のような問題が潜んでいるのです。
javascript// 危険な例:バリデーションなしの処理
function createUser(data) {
// data.email が存在するかわからない
// data.password の形式が正しいかわからない
return {
id: Math.random(),
email: data.email,
password: data.password,
};
}
上記のコードでは、入力データの検証を行っていません。これにより、以下のリスクが発生します。
リスク | 具体例 | 影響 |
---|---|---|
1 | 不正なデータ形式 | システムクラッシュ |
2 | セキュリティ脆弱性 | データ漏洩 |
3 | ユーザビリティ低下 | 離脱率向上 |
フォームバリデーションを適切に実装することで、これらのリスクを事前に防ぐことができるのです。
TypeScript の型安全性の限界
TypeScript は素晴らしい型システムを提供してくれますが、コンパイル時の型チェックには限界があります。
typescriptinterface User {
email: string;
age: number;
}
// コンパイル時は問題なし
const user: User = {
email: 'invalid-email', // 形式チェックなし
age: -5, // 論理的に不正な値
};
上記の例では、TypeScript のコンパイラはエラーを報告しません。しかし、実際の運用では以下の問題が発生する可能性があります。
フローチャートで TypeScript の制約を理解してみましょう。
mermaidflowchart TD
compile[コンパイル時] -->|型チェック| pass[型は正しい]
pass --> runtime[ランタイム実行]
runtime -->|実際の値| invalid[不正な値]
invalid --> error[エラー発生]
style compile fill:#e1f5fe
style runtime fill:#fff3e0
style error fill:#ffebee
このように、TypeScript の型システムは構造的な型安全性は提供しますが、値の妥当性までは保証してくれません。
Zod が解決する課題
Zod は、TypeScript の型システムを拡張し、ランタイムバリデーション と 型安全性 を同時に実現するライブラリです。
typescriptimport { z } from 'zod';
// Zodスキーマの定義
const UserSchema = z.object({
email: z.string().email(),
age: z.number().min(0).max(120),
});
// 型の自動推論
type User = z.infer<typeof UserSchema>;
Zod が解決する主な課題をまとめると以下のようになります。
課題 | 従来の方法 | Zod での解決 |
---|---|---|
1 | 型定義とバリデーション処理が分離 | スキーマから型を自動生成 |
2 | ランタイムエラーの予期困難 | 事前バリデーションで安全性確保 |
3 | 複雑なバリデーション処理の実装 | 宣言的な記述で可読性向上 |
課題
従来のバリデーション手法の問題点
多くの開発者が経験してきた従来のバリデーション手法には、いくつかの深刻な問題があります。
手動バリデーションの課題
javascriptfunction validateUser(data) {
const errors = {};
// メールアドレスのチェック
if (!data.email) {
errors.email = 'メールアドレスは必須です';
} else if (
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)
) {
errors.email = '有効なメールアドレスを入力してください';
}
// パスワードのチェック
if (!data.password) {
errors.password = 'パスワードは必須です';
} else if (data.password.length < 8) {
errors.password =
'パスワードは8文字以上で入力してください';
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
この手動バリデーションには以下の問題があります。
javascript// 問題1: コードの重複
function validateLoginData(data) {
// 同じメール検証を再度実装...
}
// 問題2: 型安全性の欠如
const result = validateUser(userData);
// result.errors の型が不明
ライブラリ依存の問題
既存のバリデーションライブラリを使用する場合も課題があります。
javascript// yup の例
import * as yup from 'yup';
const schema = yup.object().shape({
email: yup.string().email().required(),
age: yup.number().positive(),
});
// 問題:TypeScript の型定義が別途必要
interface User {
email: string;
age: number;
}
ランタイムとコンパイル時の型整合性
最も深刻な問題は、コンパイル時の型定義とランタイムのバリデーション処理が分離していることです。
typescript// 型定義
interface UserForm {
email: string;
password: string;
confirmPassword: string;
}
// バリデーション処理(別の場所で定義)
const validateUserForm = (data: any) => {
// email のチェック...
// password のチェック...
// confirmPassword が抜けている!
};
この問題により、以下のような事態が発生します:
- 型定義の変更がバリデーション処理に反映されない
- 新しいフィールド追加時の更新漏れ
- リファクタリング時の不整合
バリデーション処理の課題を図で整理してみましょう。
mermaidstateDiagram-v2
[*] --> TypeDefinition: 型定義
TypeDefinition --> ValidationLogic: バリデーション実装
ValidationLogic --> RuntimeCheck: ランタイムチェック
RuntimeCheck --> Error: 不整合エラー
Error --> [*]
TypeDefinition --> Update: 型更新
Update --> ValidationLogic: 更新漏れ
note right of Error: 型とバリデーションの\n不整合により発生
複雑なフォームでの型管理の困難さ
実際の Web アプリケーションでは、単純なフォームだけでなく、ネストした構造や配列を含む複雑なフォームを扱うことが多くあります。
typescript// 複雑なフォーム構造の例
interface UserRegistrationForm {
personalInfo: {
firstName: string;
lastName: string;
birthDate: Date;
};
contactInfo: {
email: string;
phone: string;
address: {
street: string;
city: string;
zipCode: string;
};
};
preferences: {
newsletter: boolean;
notifications: string[];
};
}
このような複雑な構造では、以下の課題に直面します:
javascript// 手動バリデーションが複雑になる例
function validateRegistrationForm(data) {
const errors = {};
// ネストしたオブジェクトのチェック
if (!data.personalInfo?.firstName) {
errors.personalInfo = errors.personalInfo || {};
errors.personalInfo.firstName = '名前は必須です';
}
// 配列のバリデーション
if (!Array.isArray(data.preferences?.notifications)) {
errors.preferences = errors.preferences || {};
errors.preferences.notifications =
'通知設定が正しくありません';
}
// ...何十行も続く
}
従来の手法では、このような複雑なバリデーション処理を保守しやすい形で実装することが非常に困難でした。
解決策
Zod の基本概念とスキーマ定義
Zod は スキーマファースト のアプローチを採用しています。まず、データ構造を定義するスキーマを作成し、そこから型情報とバリデーション処理の両方を自動生成します。
基本的なスキーマ定義
typescriptimport { z } from 'zod';
// 基本的な型のスキーマ
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
バリデーションルールの追加
Zod では、チェーン記法を使ってバリデーションルールを組み合わせることができます。
typescript// 文字列のバリデーション
const emailSchema = z
.string()
.min(1, 'メールアドレスは必須です')
.email('有効なメールアドレスを入力してください');
// 数値のバリデーション
const ageSchema = z
.number()
.min(0, '年齢は0以上で入力してください')
.max(120, '年齢は120以下で入力してください');
オブジェクトスキーマの定義
実際のフォームでは、複数のフィールドを持つオブジェクトを扱います。
typescript// ユーザー情報のスキーマ
const UserSchema = z.object({
email: z
.string()
.email('有効なメールアドレスを入力してください'),
password: z
.string()
.min(8, 'パスワードは8文字以上で入力してください'),
age: z.number().min(18, '18歳以上でないと登録できません'),
terms: z
.boolean()
.refine(
(val) => val === true,
'利用規約に同意してください'
),
});
Zod のスキーマ定義の流れを図で確認してみましょう。
mermaidflowchart LR
schema[スキーマ定義] --> validation[バリデーション処理]
schema --> types[TypeScript型]
validation --> safe[型安全な処理]
types --> safe
style schema fill:#e8f5e8
style safe fill:#e3f2fd
TypeScript との連携方法
Zod と TypeScript の最大の魅力は、型推論(Type Inference) によって、スキーマから自動的に TypeScript の型を生成できることです。
型の自動推論
typescriptimport { z } from 'zod';
// スキーマ定義
const UserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
age: z.number().optional(),
});
// 型の自動推論
type User = z.infer<typeof UserSchema>;
// 推論された型は以下と同等
// type User = {
// email: string;
// password: string;
// age?: number | undefined;
// }
スキーマのバリデーション実行
typescript// バリデーションの実行
function processUserData(userData: unknown) {
try {
// parse() は成功時に型安全なデータを返す
const validUser = UserSchema.parse(userData);
// この時点で validUser は User 型として扱える
console.log(`ユーザー登録: ${validUser.email}`);
return validUser;
} catch (error) {
if (error instanceof z.ZodError) {
console.error('バリデーションエラー:', error.errors);
}
throw error;
}
}
セーフパース(Safe Parse)
エラーハンドリングをより柔軟に行いたい場合は、safeParse()
メソッドを使用します。
typescriptfunction validateUserDataSafely(userData: unknown) {
const result = UserSchema.safeParse(userData);
if (result.success) {
// 成功時: result.data が型安全
const user = result.data;
return { isValid: true, user, errors: null };
} else {
// 失敗時: result.error が ZodError
return {
isValid: false,
user: null,
errors: result.error.errors,
};
}
}
フォームライブラリとの統合アプローチ
Zod は人気のフォームライブラリとシームレスに連携できます。特に React Hook Form との組み合わせは、開発体験を大幅に向上させてくれます。
React Hook Form + Zod の統合
bashyarn add react-hook-form @hookform/resolvers zod
typescriptimport { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// スキーマ定義
const LoginSchema = z.object({
email: z
.string()
.email('有効なメールアドレスを入力してください'),
password: z.string().min(1, 'パスワードは必須です'),
});
type LoginForm = z.infer<typeof LoginSchema>;
フォームコンポーネントでの利用
typescriptfunction LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(LoginSchema),
});
const onSubmit = (data: LoginForm) => {
// data は自動的に型安全になっている
console.log('ログイン情報:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type='password' {...register('password')} />
{errors.password && (
<span>{errors.password.message}</span>
)}
<button type='submit'>ログイン</button>
</form>
);
}
Formik との連携
Formik を使用する場合も、Zod スキーマを活用できます。
typescriptimport { Formik, Form, Field } from 'formik';
import { toFormikValidationSchema } from 'zod-formik-adapter';
function FormikLoginForm() {
return (
<Formik
initialValues={{ email: '', password: '' }}
validationSchema={toFormikValidationSchema(
LoginSchema
)}
onSubmit={(values) => {
console.log(values);
}}
>
{({ errors, touched }) => (
<Form>
<Field name='email' type='email' />
{errors.email && touched.email && (
<div>{errors.email}</div>
)}
<Field name='password' type='password' />
{errors.password && touched.password && (
<div>{errors.password}</div>
)}
<button type='submit'>ログイン</button>
</Form>
)}
</Formik>
);
}
具体例
シンプルなログインフォームでの実装
まずは、最も基本的なログインフォームから始めてみましょう。この例では、React Hook Form と組み合わせて実装していきます。
プロジェクトのセットアップ
bash# 必要なパッケージのインストール
yarn add react-hook-form @hookform/resolvers zod
yarn add -D @types/react @types/react-dom
ログインスキーマの定義
typescriptimport { z } from 'zod';
// ログインフォームのスキーマ定義
export const LoginSchema = z.object({
email: z
.string()
.min(1, 'メールアドレスは必須です')
.email('有効なメールアドレス形式で入力してください'),
password: z
.string()
.min(1, 'パスワードは必須です')
.min(8, 'パスワードは8文字以上で入力してください'),
});
// TypeScript 型の自動生成
export type LoginFormData = z.infer<typeof LoginSchema>;
ログインコンポーネントの実装
typescriptimport React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoginSchema, LoginFormData } from './schemas';
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(LoginSchema),
mode: 'onBlur', // ブラー時にバリデーション実行
});
const onSubmit = async (data: LoginFormData) => {
try {
console.log('ログイン処理開始:', data);
// API呼び出し処理
await simulateLoginApi(data);
alert('ログインに成功しました');
} catch (error) {
console.error('ログインエラー:', error);
alert('ログインに失敗しました');
}
};
return (
<div className='login-form'>
<h2>ログイン</h2>
<form onSubmit={handleSubmit(onSubmit)}>
<div className='form-group'>
<label htmlFor='email'>メールアドレス</label>
<input
id='email'
type='email'
{...register('email')}
className={errors.email ? 'error' : ''}
/>
{errors.email && (
<span className='error-message'>
{errors.email.message}
</span>
)}
</div>
<div className='form-group'>
<label htmlFor='password'>パスワード</label>
<input
id='password'
type='password'
{...register('password')}
className={errors.password ? 'error' : ''}
/>
{errors.password && (
<span className='error-message'>
{errors.password.message}
</span>
)}
</div>
<button type='submit' disabled={isSubmitting}>
{isSubmitting ? 'ログイン中...' : 'ログイン'}
</button>
</form>
</div>
);
}
API 通信での活用
typescript// APIリクエスト時のバリデーション
async function simulateLoginApi(data: unknown) {
// サーバーサイドでも同じスキーマでバリデーション
const validatedData = LoginSchema.parse(data);
// 安全にAPIリクエストを実行
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validatedData),
});
if (!response.ok) {
throw new Error('ログインに失敗しました');
}
return response.json();
}
複雑なユーザー登録フォームの構築
続いて、より複雑なユーザー登録フォームを実装してみましょう。この例では、複数のステップとネストしたオブジェクト構造を扱います。
複雑なスキーマ定義
typescriptimport { z } from 'zod';
// 日本の郵便番号形式(例:123-4567)
const zipCodeRegex = /^\d{3}-\d{4}$/;
// 電話番号形式(例:090-1234-5678)
const phoneRegex = /^\d{2,4}-\d{2,4}-\d{4}$/;
export const UserRegistrationSchema = z
.object({
// 基本情報
personalInfo: z.object({
firstName: z.string().min(1, '名前は必須です'),
lastName: z.string().min(1, '姓は必須です'),
birthDate: z.string().refine((date) => {
const parsedDate = new Date(date);
const now = new Date();
const age =
now.getFullYear() - parsedDate.getFullYear();
return age >= 18;
}, '18歳以上でないと登録できません'),
}),
// 連絡先情報
contactInfo: z.object({
email: z
.string()
.email('有効なメールアドレスを入力してください'),
phone: z
.string()
.regex(
phoneRegex,
'有効な電話番号を入力してください'
),
address: z.object({
zipCode: z
.string()
.regex(
zipCodeRegex,
'郵便番号は123-4567の形式で入力してください'
),
prefecture: z
.string()
.min(1, '都道府県を選択してください'),
city: z.string().min(1, '市区町村は必須です'),
street: z.string().min(1, '住所は必須です'),
}),
}),
// アカウント情報
accountInfo: z.object({
username: z
.string()
.min(3, 'ユーザー名は3文字以上で入力してください')
.max(20, 'ユーザー名は20文字以下で入力してください')
.regex(
/^[a-zA-Z0-9_]+$/,
'ユーザー名は英数字とアンダースコアのみ使用できます'
),
password: z
.string()
.min(8, 'パスワードは8文字以上で入力してください')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'パスワードは大文字・小文字・数字を含む必要があります'
),
confirmPassword: z.string(),
}),
// 設定・同意
preferences: z.object({
newsletter: z.boolean(),
notifications: z.array(
z.enum(['email', 'sms', 'push'])
),
privacyPolicy: z
.boolean()
.refine(
(val) => val === true,
'プライバシーポリシーに同意してください'
),
termsOfService: z
.boolean()
.refine(
(val) => val === true,
'利用規約に同意してください'
),
}),
})
.refine(
(data) => {
// パスワード確認のカスタムバリデーション
return (
data.accountInfo.password ===
data.accountInfo.confirmPassword
);
},
{
message: 'パスワードが一致しません',
path: ['accountInfo', 'confirmPassword'],
}
);
export type UserRegistrationData = z.infer<
typeof UserRegistrationSchema
>;
多段フォームコンポーネントの実装
typescriptimport React, { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
UserRegistrationSchema,
UserRegistrationData,
} from './schemas';
const FORM_STEPS = [
{ id: 'personal', title: '基本情報' },
{ id: 'contact', title: '連絡先情報' },
{ id: 'account', title: 'アカウント情報' },
{ id: 'preferences', title: '設定・同意' },
];
export function UserRegistrationForm() {
const [currentStep, setCurrentStep] = useState(0);
const methods = useForm<UserRegistrationData>({
resolver: zodResolver(UserRegistrationSchema),
mode: 'onBlur',
defaultValues: {
personalInfo: {
firstName: '',
lastName: '',
birthDate: '',
},
contactInfo: {
email: '',
phone: '',
address: {
zipCode: '',
prefecture: '',
city: '',
street: '',
},
},
accountInfo: {
username: '',
password: '',
confirmPassword: '',
},
preferences: {
newsletter: false,
notifications: [],
privacyPolicy: false,
termsOfService: false,
},
},
});
const onSubmit = async (data: UserRegistrationData) => {
try {
console.log('ユーザー登録データ:', data);
await simulateRegistrationApi(data);
alert('登録が完了しました');
} catch (error) {
console.error('登録エラー:', error);
alert('登録に失敗しました');
}
};
const nextStep = () => {
if (currentStep < FORM_STEPS.length - 1) {
setCurrentStep(currentStep + 1);
}
};
const prevStep = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
return (
<FormProvider {...methods}>
<div className='registration-form'>
<h2>ユーザー登録</h2>
{/* プログレスバー */}
<div className='progress-bar'>
{FORM_STEPS.map((step, index) => (
<div
key={step.id}
className={`step ${
index === currentStep ? 'active' : ''
}
${
index < currentStep
? 'completed'
: ''
}`}
>
{step.title}
</div>
))}
</div>
<form onSubmit={methods.handleSubmit(onSubmit)}>
{currentStep === 0 && <PersonalInfoStep />}
{currentStep === 1 && <ContactInfoStep />}
{currentStep === 2 && <AccountInfoStep />}
{currentStep === 3 && <PreferencesStep />}
<div className='form-navigation'>
{currentStep > 0 && (
<button type='button' onClick={prevStep}>
前へ
</button>
)}
{currentStep < FORM_STEPS.length - 1 ? (
<button type='button' onClick={nextStep}>
次へ
</button>
) : (
<button type='submit'>登録完了</button>
)}
</div>
</form>
</div>
</FormProvider>
);
}
ユーザー登録フォームの全体フローを図で確認してみましょう。
mermaidsequenceDiagram
participant User
participant Form
participant Zod
participant API
User->>Form: 基本情報入力
Form->>Zod: バリデーション実行
Zod->>Form: 結果返却
User->>Form: 次へボタンクリック
Form->>Form: ステップ切り替え
User->>Form: 最終ステップ完了
Form->>Zod: 全データバリデーション
Zod->>API: 型安全なデータ送信
API->>Form: 登録完了応答
ネストした構造のフォーム処理
複雑なフォームでは、オブジェクトの配列や深くネストした構造を扱うことがあります。Zod はこのような構造も直感的に定義できます。
動的配列フィールドのスキーマ
typescriptimport { z } from 'zod';
// 職歴情報のスキーマ
const WorkExperienceSchema = z.object({
company: z.string().min(1, '会社名は必須です'),
position: z.string().min(1, '役職は必須です'),
startDate: z.string().min(1, '開始日は必須です'),
endDate: z.string().optional(),
description: z.string().optional(),
isCurrent: z.boolean(),
});
// プロフィール全体のスキーマ
export const ProfileSchema = z.object({
basicInfo: z.object({
name: z.string().min(1, '名前は必須です'),
title: z.string().min(1, '職種は必須です'),
summary: z
.string()
.max(500, '自己紹介は500文字以内で入力してください'),
}),
workExperiences: z
.array(WorkExperienceSchema)
.min(1, '職歴は最低1つ入力してください'),
skills: z
.array(z.string())
.min(3, 'スキルは最低3つ入力してください'),
languages: z.array(
z.object({
name: z.string().min(1, '言語名は必須です'),
level: z.enum([
'beginner',
'intermediate',
'advanced',
'native',
]),
})
),
});
export type ProfileData = z.infer<typeof ProfileSchema>;
動的配列コンポーネントの実装
typescriptimport React from 'react';
import {
useFieldArray,
useFormContext,
} from 'react-hook-form';
import { ProfileData } from './schemas';
export function WorkExperienceSection() {
const {
register,
control,
formState: { errors },
} = useFormContext<ProfileData>();
const { fields, append, remove } = useFieldArray({
control,
name: 'workExperiences',
});
const addExperience = () => {
append({
company: '',
position: '',
startDate: '',
endDate: '',
description: '',
isCurrent: false,
});
};
return (
<div className='work-experience-section'>
<h3>職歴</h3>
{fields.map((field, index) => (
<div key={field.id} className='experience-item'>
<h4>職歴 {index + 1}</h4>
<div className='form-row'>
<div className='form-group'>
<label>会社名</label>
<input
{...register(
`workExperiences.${index}.company`
)}
placeholder='株式会社〇〇'
/>
{errors.workExperiences?.[index]?.company && (
<span className='error-message'>
{
errors.workExperiences[index].company
.message
}
</span>
)}
</div>
<div className='form-group'>
<label>役職</label>
<input
{...register(
`workExperiences.${index}.position`
)}
placeholder='フロントエンドエンジニア'
/>
{errors.workExperiences?.[index]
?.position && (
<span className='error-message'>
{
errors.workExperiences[index].position
.message
}
</span>
)}
</div>
</div>
<div className='form-row'>
<div className='form-group'>
<label>開始日</label>
<input
type='date'
{...register(
`workExperiences.${index}.startDate`
)}
/>
</div>
<div className='form-group'>
<label>終了日</label>
<input
type='date'
{...register(
`workExperiences.${index}.endDate`
)}
disabled={methods.watch(
`workExperiences.${index}.isCurrent`
)}
/>
</div>
</div>
<div className='form-group'>
<label>
<input
type='checkbox'
{...register(
`workExperiences.${index}.isCurrent`
)}
/>
現在もこの職場で働いている
</label>
</div>
<div className='form-group'>
<label>業務内容</label>
<textarea
{...register(
`workExperiences.${index}.description`
)}
placeholder='担当していた業務内容を入力してください'
rows={3}
/>
</div>
<button
type='button'
onClick={() => remove(index)}
className='remove-button'
>
この職歴を削除
</button>
</div>
))}
<button
type='button'
onClick={addExperience}
className='add-button'
>
職歴を追加
</button>
</div>
);
}
エラーハンドリングと UI 表示
Zod のエラーハンドリングは非常に柔軟で、詳細なエラー情報を提供してくれます。
カスタムエラーメッセージの実装
typescriptimport { z } from 'zod';
// カスタムバリデーション関数
const strongPassword = z
.string()
.min(8, 'パスワードは8文字以上で入力してください')
.refine(
(password) => /[A-Z]/.test(password),
'パスワードには大文字を含めてください'
)
.refine(
(password) => /[a-z]/.test(password),
'パスワードには小文字を含めてください'
)
.refine(
(password) => /[0-9]/.test(password),
'パスワードには数字を含めてください'
)
.refine(
(password) => /[^A-Za-z0-9]/.test(password),
'パスワードには特殊文字を含めてください'
);
エラー表示コンポーネント
typescriptimport React from 'react';
import { FieldError, FieldErrors } from 'react-hook-form';
interface ErrorDisplayProps {
error?: FieldError;
className?: string;
}
export function ErrorDisplay({
error,
className = 'error-message',
}: ErrorDisplayProps) {
if (!error) return null;
return (
<div className={className}>
<span>{error.message}</span>
</div>
);
}
// 複数エラーの表示
interface MultipleErrorsProps {
errors: FieldErrors;
fieldName: string;
}
export function MultipleErrors({
errors,
fieldName,
}: MultipleErrorsProps) {
const fieldErrors = errors[fieldName];
if (!fieldErrors) return null;
return (
<div className='error-list'>
{Array.isArray(fieldErrors) ? (
fieldErrors.map((error, index) => (
<ErrorDisplay key={index} error={error} />
))
) : (
<ErrorDisplay error={fieldErrors} />
)}
</div>
);
}
リアルタイムバリデーション
typescriptimport React, { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import { debounce } from 'lodash';
export function RealtimeValidatedField({
name,
label,
...props
}) {
const {
register,
watch,
trigger,
formState: { errors },
} = useFormContext();
const fieldValue = watch(name);
// デバウンスされたバリデーション実行
const debouncedTrigger = debounce(() => {
trigger(name);
}, 500);
useEffect(() => {
if (fieldValue) {
debouncedTrigger();
}
return () => {
debouncedTrigger.cancel();
};
}, [fieldValue, debouncedTrigger]);
return (
<div className='form-group'>
<label>{label}</label>
<input
{...register(name)}
{...props}
className={errors[name] ? 'error' : ''}
/>
<ErrorDisplay error={errors[name]} />
</div>
);
}
まとめ
Zod 導入のメリット
この記事を通じて、Zod を使った型安全なフォームバリデーションの実装方法を学んできました。Zod 導入による主なメリットをまとめてみましょう。
開発体験の向上
メリット | 従来の方法 | Zod 使用時 |
---|---|---|
型安全性 | 手動で型定義とバリデーション処理を分離 | スキーマから自動的に型推論 |
コード重複 | 同じバリデーション処理を何度も実装 | スキーマを再利用可能 |
保守性 | 型変更時にバリデーション処理の更新が必要 | スキーマ更新だけで両方に反映 |
エラーハンドリング | カスタムエラー処理の実装が複雑 | 詳細なエラー情報を自動提供 |
パフォーマンスと品質の向上
Zod を導入することで、以下のような品質向上が期待できます:
typescript// バグの早期発見例
const UserSchema = z.object({
email: z.string().email(),
age: z.number().min(0),
});
// コンパイル時に型エラーを検出
const invalidUser = {
email: 'test@example.com',
age: 'invalid', // TypeScriptがエラーを検出
};
// ランタイムでもバリデーションエラーを検出
const result = UserSchema.safeParse(invalidUser);
if (!result.success) {
console.log('バリデーションエラー:', result.error.errors);
}
チーム開発での効果
typescript// API仕様の共有
export const CreateUserRequestSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
profile: z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
}),
});
// フロントエンドとバックエンドで同じスキーマを使用
type CreateUserRequest = z.infer<
typeof CreateUserRequestSchema
>;
開発チームで Zod を活用する利点を図で整理してみましょう。
mermaidflowchart TD
schema[Zodスキーマ定義] --> frontend[フロントエンド開発]
schema --> backend[バックエンド開発]
schema --> testing[テスト実装]
frontend --> validation[バリデーション処理]
backend --> api[API実装]
testing --> testcase[テストケース作成]
validation --> consistency[型の整合性]
api --> consistency
testcase --> consistency
style schema fill:#e8f5e8
style consistency fill:#e3f2fd
次のステップ
Zod の基本をマスターした次は、以下のトピックに挑戦してみてください。
より高度なバリデーション
typescript// 条件付きバリデーション
const ConditionalSchema = z
.object({
userType: z.enum(['individual', 'business']),
profile: z.union([
z.object({
firstName: z.string(),
lastName: z.string(),
}),
z.object({
companyName: z.string(),
taxId: z.string(),
}),
]),
})
.refine((data) => {
if (data.userType === 'individual') {
return 'firstName' in data.profile;
}
return 'companyName' in data.profile;
});
サーバーサイドでの活用
typescript// Next.js API Routesでの使用例
import { NextApiRequest, NextApiResponse } from 'next';
import { UserSchema } from '../schemas/user';
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const userData = UserSchema.parse(req.body);
// 型安全なデータベース操作
const user = await createUser(userData);
res.status(201).json(user);
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ errors: error.errors });
}
res.status(500).json({ error: '内部エラー' });
}
}
テストでの活用
typescriptimport { describe, it, expect } from 'vitest';
import { UserSchema } from './schemas';
describe('ユーザーバリデーション', () => {
it('有効なデータの場合、バリデーションが成功する', () => {
const validData = {
email: 'test@example.com',
password: 'SecurePass123!',
};
const result = UserSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('無効なメールアドレスの場合、バリデーションが失敗する', () => {
const invalidData = {
email: 'invalid-email',
password: 'SecurePass123!',
};
const result = UserSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors[0].path).toEqual([
'email',
]);
}
});
});
継続的な学習のために、以下のリソースも活用してみてください:
- 公式ドキュメント での詳細な API 仕様の確認
- 実際のプロジェクト での段階的導入
- チームメンバー とのベストプラクティス共有
- パフォーマンス測定 による効果検証
Zod を使いこなすことで、より安全で保守しやすい Web アプリケーションを構築できるようになります。まずは小さなフォームから始めて、段階的に複雑な実装に挑戦してみてください。
関連リンク
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来