Zod で非同期バリデーション(async)を実装する方法

モダンな Web 開発において、フォームバリデーションは単純な文字列チェックだけでは済まなくなりました。ユーザー名の重複確認やメールアドレスの存在確認など、外部 API やデータベースとの連携が必要な場面が増えています。
TypeScript のスキーマバリデーションライブラリとして人気の高い Zod ですが、標準機能だけでは非同期処理に対応できません。しかし、Zod の refine
メソッドを活用することで、Promise ベースの非同期バリデーションを実装できるのです。
本記事では、Zod での非同期バリデーション実装について、基本的な仕組みから実際のユースケースまで、段階的に解説いたします。
背景
Zod の標準的なバリデーションの仕組み
Zod は、TypeScript で型安全なスキーマバリデーションを提供するライブラリです。通常のバリデーションは同期的に実行されます。
以下は基本的な Zod バリデーションの例です:
typescriptimport { z } from 'zod';
// 基本的なスキーマ定義
const userSchema = z.object({
name: z
.string()
.min(2, 'ユーザー名は2文字以上で入力してください'),
email: z
.string()
.email('正しいメールアドレスを入力してください'),
age: z.number().min(18, '18歳以上である必要があります'),
});
このようなバリデーションは、入力値に対して即座に結果を返すため、処理が高速で UI の応答性が良いという特徴があります。
typescript// 同期バリデーションの実行
const validateUser = (userData: unknown) => {
try {
const validatedData = userSchema.parse(userData);
console.log('バリデーション成功:', validatedData);
return { success: true, data: validatedData };
} catch (error) {
console.log('バリデーションエラー:', error);
return { success: false, error };
}
};
同期処理と非同期処理の違い
同期処理では、コードが順次実行され、一つの処理が完了するまで次の処理は待機状態となります。一方、非同期処理では、時間のかかる処理(API 呼び出しなど)を並行して実行できます。
Zod のバリデーション処理における違いを図で表すと以下のようになります:
mermaidflowchart TD
input[入力データ] --> sync[同期バリデーション]
sync --> result1[即座に結果返却]
input2[入力データ] --> async[非同期バリデーション]
async --> api[API/DB問い合わせ]
api --> wait[待機時間]
wait --> result2[結果返却]
style sync fill:#e1f5fe
style async fill:#fff3e0
style api fill:#fce4ec
この図から分かるように、非同期バリデーションでは外部リソースへの問い合わせが必要になるため、待機時間が発生します。
非同期バリデーションが必要になるケース
現代の Web アプリケーションでは、以下のようなケースで非同期バリデーションが求められます:
項目 | 用途 | 実装例 |
---|---|---|
ユーザー名重複確認 | 新規登録時の重複チェック | /api/users/check-username |
メールアドレス存在確認 | 有効性・到達可能性の確認 | メール配信 API 連携 |
在庫確認 | EC サイトでの商品購入前チェック | 在庫管理システム連携 |
権限確認 | ユーザーの操作権限チェック | 認証・認可システム連携 |
地域・郵便番号確認 | 住所入力時の妥当性確認 | 郵便番号 API との連携 |
これらの要件に対応するため、Zod での非同期バリデーション実装が必要となるのです。
課題
標準的な Zod では対応できない非同期処理
Zod の標準的なバリデーションメソッドは、すべて同期的に動作するように設計されています。以下のような制限があります:
typescript// これは動作しません - async/await を直接使用できない
const invalidSchema = z.object({
username: z.string().refine(async (val) => {
// Error: refine関数内でasyncは直接サポートされていない
const response = await fetch(
`/api/check-username/${val}`
);
return response.ok;
}),
});
この制限により、外部 API やデータベースとの連携が必要なバリデーションを実装する際に課題が生じます。
API 呼び出しやデータベース検証の必要性
モダンな Web アプリケーションでは、以下のような検証要件が一般的です:
typescript// ユーザー登録フォームでの要件例
interface RegistrationRequirements {
username: string; // データベースで重複チェックが必要
email: string; // メール配信サービスで有効性確認が必要
companyCode: string; // 企業マスタAPIで存在確認が必要
}
これらの要件を満たすためには、必然的に非同期処理が必要となります。
パフォーマンスとユーザビリティの両立
非同期バリデーションを実装する際は、以下の課題に対処する必要があります:
mermaidflowchart LR
user[ユーザー入力] --> validation[バリデーション処理]
validation --> api1[API呼び出し1]
validation --> api2[API呼び出し2]
validation --> api3[API呼び出し3]
api1 --> wait1[待機時間]
api2 --> wait2[待機時間]
api3 --> wait3[待機時間]
wait1 --> result[結果統合]
wait2 --> result
wait3 --> result
result --> feedback[ユーザーフィードバック]
style wait1 fill:#ffcdd2
style wait2 fill:#ffcdd2
style wait3 fill:#ffcdd2
主な課題として以下が挙げられます:
- レスポンス時間の増加: API 呼び出しによる待機時間
- ネットワークエラーの処理: 通信障害時の適切な対応
- ユーザー体験の低下: 長時間の待機によるフラストレーション
- リソース消費: 大量の API 呼び出しによるサーバー負荷
これらの課題を解決するため、適切な非同期バリデーション実装パターンが必要となります。
解決策
z.refine()
メソッドによる非同期バリデーション
Zod では、z.refine()
メソッドに Promise を返す関数を渡すことで、非同期バリデーションを実装できます。
基本的な実装パターンは以下の通りです:
typescriptimport { z } from 'zod';
// 非同期バリデーション関数の定義
const checkUsernameAvailability = async (
username: string
): Promise<boolean> => {
try {
const response = await fetch(
`/api/users/check/${username}`
);
const data = await response.json();
return data.available;
} catch (error) {
// ネットワークエラー時は true を返して処理を続行
console.error('Username check failed:', error);
return true;
}
};
この関数を使用してスキーマを定義します:
typescript// 非同期バリデーションを含むスキーマ
const userRegistrationSchema = z.object({
username: z
.string()
.min(3, 'ユーザー名は3文字以上で入力してください')
.refine(
(username) => checkUsernameAvailability(username),
{
message: 'このユーザー名は既に使用されています',
}
),
email: z
.string()
.email('正しいメールアドレスを入力してください'),
});
Promise
を返すカスタムバリデーション関数
より複雑な非同期バリデーションを実装する場合は、カスタムバリデーション関数を作成します:
typescript// 複数の条件をチェックする非同期バリデーション
const validateEmailComprehensive = async (
email: string
): Promise<boolean> => {
// Step 1: 基本的なフォーマットチェック(同期)
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return false;
}
// Step 2: ドメイン存在確認(非同期)
try {
const domainResponse = await fetch(
`/api/validate/domain`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
}
);
if (!domainResponse.ok) {
return false;
}
const domainData = await domainResponse.json();
return domainData.valid;
} catch (error) {
console.error('Domain validation failed:', error);
return true; // エラー時は検証をスキップ
}
};
このバリデーション関数をスキーマに組み込みます:
typescriptconst contactSchema = z.object({
email: z
.string()
.email('メールアドレスの形式が正しくありません')
.refine((email) => validateEmailComprehensive(email), {
message:
'このメールアドレスは無効またはアクセスできません',
}),
});
エラーハンドリングとメッセージ設定
非同期バリデーションでは、適切なエラーハンドリングが重要です:
typescript// 詳細なエラー情報を含むバリデーション関数
const validateWithDetailedErrors = async (
value: string
): Promise<boolean> => {
try {
const response = await fetch('/api/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
signal: AbortSignal.timeout(5000), // 5秒でタイムアウト
});
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: ${response.statusText}`
);
}
const result = await response.json();
return result.valid;
} catch (error) {
if (error instanceof Error) {
console.error('Validation error:', error.message);
}
// エラー時のフォールバック処理
return true;
}
};
カスタムエラーメッセージの設定も可能です:
typescriptconst schemaWithCustomErrors = z.object({
data: z.string().refine(validateWithDetailedErrors, {
message:
'サーバーでの検証に失敗しました。しばらく待ってから再試行してください',
}),
});
非同期バリデーションの実行時は、parseAsync
メソッドを使用します:
typescript// 非同期バリデーションの実行
const validateAsync = async (inputData: unknown) => {
try {
const validatedData =
await schemaWithCustomErrors.parseAsync(inputData);
console.log('バリデーション成功:', validatedData);
return { success: true, data: validatedData };
} catch (error) {
console.log('バリデーションエラー:', error);
return { success: false, error };
}
};
これらの基本パターンを理解することで、様々な非同期バリデーション要件に対応できるようになります。
具体例
ユーザー名重複チェックの実装
ユーザー登録システムで最も一般的な非同期バリデーションの一つが、ユーザー名の重複チェックです。
まず、API エンドポイントとの通信を行う関数を作成します:
typescript// ユーザー名重複チェック用のAPI関数
const checkUsernameExists = async (
username: string
): Promise<boolean> => {
try {
const response = await fetch(
`/api/users/check-username`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username }),
}
);
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
const result = await response.json();
return !result.exists; // 存在しない場合は true(利用可能)
} catch (error) {
console.error('Username check failed:', error);
return true; // エラー時は利用可能として処理を続行
}
};
デバウンス機能を追加して、ユーザーの入力中に過度な API 呼び出しを防ぎます:
typescript// デバウンス機能付きのチェック関数
class UsernameValidator {
private timeoutId: NodeJS.Timeout | null = null;
private cache = new Map<string, boolean>();
async validateWithDebounce(
username: string,
delay = 500
): Promise<boolean> {
// キャッシュから結果を取得
if (this.cache.has(username)) {
return this.cache.get(username)!;
}
// 既存のタイムアウトをクリア
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
return new Promise((resolve) => {
this.timeoutId = setTimeout(async () => {
const isAvailable = await checkUsernameExists(
username
);
this.cache.set(username, isAvailable);
resolve(isAvailable);
}, delay);
});
}
}
const usernameValidator = new UsernameValidator();
このバリデーターを Zod スキーマに組み込みます:
typescriptconst userRegistrationSchema = z.object({
username: z
.string()
.min(3, 'ユーザー名は3文字以上で入力してください')
.max(20, 'ユーザー名は20文字以下で入力してください')
.regex(
/^[a-zA-Z0-9_]+$/,
'英数字とアンダースコアのみ使用できます'
)
.refine(
(username) =>
usernameValidator.validateWithDebounce(username),
{
message: 'このユーザー名は既に使用されています',
}
),
email: z
.string()
.email('正しいメールアドレスを入力してください'),
password: z
.string()
.min(8, 'パスワードは8文字以上で入力してください'),
});
メールアドレス存在確認
メールアドレスの存在確認は、より複雑なバリデーションプロセスが必要です:
typescript// メールアドレス検証のための複数ステップ関数
const validateEmailExistence = async (
email: string
): Promise<boolean> => {
const [localPart, domain] = email.split('@');
// Step 1: ドメインのMXレコード確認
const domainValid = await checkDomainMX(domain);
if (!domainValid) {
return false;
}
// Step 2: 使い捨てメールアドレスのチェック
const isDisposable = await checkDisposableEmail(domain);
if (isDisposable) {
return false;
}
// Step 3: 既存ユーザーとの重複チェック
const isExistingUser = await checkExistingEmail(email);
return !isExistingUser;
};
// MXレコード確認関数
const checkDomainMX = async (
domain: string
): Promise<boolean> => {
try {
const response = await fetch(
`/api/email/check-domain`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain }),
}
);
const result = await response.json();
return result.hasValidMX;
} catch (error) {
console.error('Domain MX check failed:', error);
return true; // エラー時は有効として処理
}
};
使い捨てメールアドレスの検証:
typescript// 使い捨てメールアドレスのチェック
const checkDisposableEmail = async (
domain: string
): Promise<boolean> => {
try {
const response = await fetch(
`/api/email/check-disposable`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain }),
}
);
const result = await response.json();
return result.isDisposable;
} catch (error) {
console.error('Disposable email check failed:', error);
return false; // エラー時は使い捨てではないとして処理
}
};
// 既存ユーザーとの重複チェック
const checkExistingEmail = async (
email: string
): Promise<boolean> => {
try {
const response = await fetch(`/api/users/check-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const result = await response.json();
return result.exists;
} catch (error) {
console.error('Email existence check failed:', error);
return false; // エラー時は存在しないとして処理
}
};
API レスポンス検証
外部 API からのレスポンスを検証する場合の実装例です:
typescript// API レスポンスの構造を定義
const apiResponseSchema = z.object({
success: z.boolean(),
data: z.object({
id: z.number(),
name: z.string(),
status: z.enum(['active', 'inactive', 'pending']),
}),
timestamp: z.string().datetime(),
});
// API レスポンス検証関数
const validateApiResponse = async (
endpoint: string,
payload: unknown
): Promise<boolean> => {
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
return false;
}
const responseData = await response.json();
// Zod でレスポンス構造を検証
const validatedResponse =
apiResponseSchema.parse(responseData);
// ビジネスロジックに基づく追加検証
return (
validatedResponse.success &&
validatedResponse.data.status === 'active'
);
} catch (error) {
console.error('API response validation failed:', error);
return false;
}
};
フォーム全体での使用例:
typescript// 複数の非同期バリデーションを組み合わせたスキーマ
const comprehensiveFormSchema = z.object({
username: z
.string()
.min(3)
.refine(
(val) => usernameValidator.validateWithDebounce(val),
{ message: 'ユーザー名が既に使用されています' }
),
email: z
.string()
.email()
.refine((val) => validateEmailExistence(val), {
message: 'このメールアドレスは使用できません',
}),
apiKey: z
.string()
.refine(
(val) =>
validateApiResponse('/api/validate-key', {
key: val,
}),
{ message: 'APIキーが無効です' }
),
});
// フォームバリデーションの実行
const handleFormSubmission = async (formData: unknown) => {
try {
const validatedData =
await comprehensiveFormSchema.parseAsync(formData);
console.log(
'全てのバリデーションが成功しました:',
validatedData
);
return { success: true, data: validatedData };
} catch (error) {
console.error('バリデーションエラー:', error);
return { success: false, error };
}
};
これらの具体例では、実際のプロダクション環境で遭遇する様々な非同期バリデーション要件をカバーしています。各例では、エラーハンドリング、パフォーマンス最適化、ユーザビリティの向上を考慮した実装パターンを示しています。
まとめ
Zod での非同期バリデーション実装について、基本的な仕組みから実践的な応用例まで詳しく解説いたしました。
重要なポイント
基本実装について
z.refine()
メソッドに Promise を返す関数を渡すことで非同期バリデーションが可能parseAsync()
メソッドを使用して非同期バリデーションを実行- 適切なエラーハンドリングとフォールバック処理が必須
パフォーマンス最適化
- デバウンス機能による API 呼び出し頻度の制御
- キャッシュ機能による重複リクエストの回避
- タイムアウト設定による応答性の確保
実用的な応用
- ユーザー名重複チェックでのリアルタイム検証
- メールアドレスの多段階検証(MX レコード、使い捨てチェック、重複確認)
- 外部 API レスポンスの構造・内容検証
実装時の注意点
以下の点に注意して実装することで、堅牢で使いやすい非同期バリデーション機能を構築できます:
注意点 | 対策 |
---|---|
ネットワークエラー対応 | try-catch での適切なエラーハンドリング |
パフォーマンス低下 | デバウンス・キャッシュ・タイムアウト設定 |
ユーザー体験 | ローディング表示・適切なエラーメッセージ |
セキュリティ | 入力値のサニタイズ・レート制限 |
非同期バリデーションは、モダンな Web アプリケーションにおいて必要不可欠な機能です。Zod の柔軟性を活用することで、型安全性を保ちながら複雑なバリデーション要件にも対応できるでしょう。
実装の際は、ユーザビリティとパフォーマンスのバランスを考慮し、段階的にアプローチすることをお勧めいたします。
関連リンク
- Zod 公式ドキュメント - Zod の公式ドキュメントと API リファレンス
- Zod GitHub リポジトリ - ソースコードと最新の更新情報
- TypeScript 公式ドキュメント - TypeScript の型システムと非同期処理
- MDN - Promise - JavaScript における Promise の詳細解説
- MDN - async/await - 非同期関数の基本的な使い方
- React Hook Form - React での フォームバリデーション実装例
- Formik - もう一つの人気フォームライブラリとの連携方法
- article
Zod で非同期バリデーション(async)を実装する方法
- article
Zod で配列・オブジェクトを安全に扱うバリデーションテクニック
- article
【実践】Zod の union・discriminatedUnion を使った柔軟な型定義
- article
Zod で条件付きバリデーションを実装する方法(if/then/else パターン)
- article
【徹底解説】Zod の refine と superRefine の違いと実践活用シーン
- article
Zod と React Hook Form を組み合わせて使う方法と実装例
- article
Zod で非同期バリデーション(async)を実装する方法
- article
Node.js スクリプトからサービスへ:systemd や pm2 による常駐運用
- article
Web Components Shadow DOM を使いこなす - スタイルカプセル化と Slot 活用テクニック
- article
Next.js の Middleware 活用法:リクエスト制御・認証・リダイレクトの実践例
- article
Vue.js のエラーと警告メッセージを完全理解
- article
Tailwind CSS × Three.js でインタラクティブな 3D 表現を実装
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来