Web アプリケーション開発において、ユーザー入力の検証は不可欠です。しかし、ユーザー名の重複確認やメール住所の実在確認など、サーバー側との通信を伴う検証邏輯は、従来の同期処理では対応が困難です。Zod を利用することで、TypeScript の型安全性を保ちつつ、非同期検証をスムーズに実装できます。
非同期検証が必要な場面
現代の Web サービスでは、以下のようなケースでバックエンドとの連携が必要になります。
- アカウント ID の一意性確認
- 登録済みメールアドレスの重複チェック
- プロモーションコードの有効性検証
- 外部 API を通じた住所情報の照合
これらの要件を満たすには、Zod が提供する非同期検証機能を活用するのが効果的です。
Zod 非同期検証の基礎
Zod では、非同期処理を含む検証のために parseAsync と safeParseAsync の 2 つのメソッドが用意されています。まずはライブラリのインストールを行います。
npm i zod
# または
yarn add zod
基本的な実装例を以下に示します。
import { z } from "zod";
// 非同期検証を含むスキーマ定義
const membershipSchema = z.object({
handleId: z.string().refine(
async (id) => {
// API を呼び出して ID の可用性を確認
const res = await fetch(`/api/verify-id?val=${id}`);
const data = await res.json();
return data.isAvailable; // 布尔値を返す
},
{ message: "この ID は既に使用されています" }
),
contactEmail: z.string().email().refine(
async (mail) => {
// メールアドレスの登録状況を確認
const res = await fetch(`/api/verify-mail?addr=${mail}`);
return !(await res.json()).isRegistered;
},
{ message: "このメールアドレスは登録済みです" }
)
});
// 検証実行関数
async function processMembership(inputData) {
try {
const validatedData = await membershipSchema.parseAsync(inputData);
return { status: "ok", payload: validatedData };
} catch (err) {
return { status: "fail", issues: err.errors };
}
}
主要 API の違い
非同期検証において主に使用されるメソッドは以下の通りです。
- parseAsync: 検証に失敗した場合に例外をスローします。データが必ず有効であるべき場面で使用します。
- safeParseAsync: 例外をスローせず、結果オブジェクトを返します。エラーハンドリングを柔軟に行いたい場合に適しています。
- refine: カスタム検証邏輯を追加します。非同期関数を渡すことが可能です。
実装例:ユーザー登録フォーム
具体的なユーザー登録フローを通じて、非同期検証の適用方法を確認します。
1. スキーマの定義
パスワード規則と非同期チェックを組み合わせたスキーマを作成します。
import { z } from "zod";
const credentialSchema = z.string()
.min(8, "パスワードは 8 文字以上必要です")
.refine(val => /[A-Z]/.test(val), "大文字を含めてください")
.refine(val => /[0-9]/.test(val), "数字を含めてください");
const signupSchema = z.object({
handleId: z.string()
.min(3, "ID は 3 文字以上")
.max(20, "ID は 20 文字以下")
.refine(
async (id) => {
// API 呼び出しの模擬
const res = await new Promise(resolve =>
setTimeout(() => resolve({ ok: id !== "administrator" }), 500)
);
return res.ok;
},
{ message: "この ID は使用できません" }
),
contactEmail: z.string()
.email("有効なメールアドレスを入力してください")
.refine(
async (mail) => {
return await new Promise(resolve =>
setTimeout(() => resolve(!mail.includes("blocked")), 500)
);
},
{ message: "このメールアドレスは登録できません" }
),
passPhrase: credentialSchema,
passPhraseConfirm: credentialSchema
}).refine(data => data.passPhrase === data.passPhraseConfirm, {
message: "パスワードが一致しません",
path: ["passPhraseConfirm"]
});
2. 検証ロジックの実装
safeParseAsync を利用して、エラー情報を構造化して返す関数を用意します。
async function verifySignup(input) {
const result = await signupSchema.safeParseAsync(input);
if (!result.success) {
const errorMap = result.error.issues.reduce((acc, issue) => {
acc[issue.path.join('.')] = issue.message;
return acc;
}, {});
return { isValid: false, issues: errorMap };
}
return { isValid: true, payload: result.data };
}
3. フォーム送信処理
UI 側での送信ハンドラーでは、検証結果に応じて状態を更新します。
async function onSubmitHandler(formData) {
setLoading(true);
clearErrors();
const { isValid, issues, payload } = await verifySignup({
handleId: formData.handleId,
contactEmail: formData.contactEmail,
passPhrase: formData.passPhrase,
passPhraseConfirm: formData.passPhraseConfirm
});
if (!isValid) {
setErrors(issues);
setLoading(false);
return;
}
// 検証成功後にサーバーへ送信
try {
await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
// 成功処理
} catch (err) {
// エラー処理
} finally {
setLoading(false);
}
}
最適化テクニック
1. リクエスト頻度の抑制
API 呼び出しを頻繁に行わないよう、入力に対して防抖(debounce)処理を適用します。
import { debounce } from 'lodash';
const checkIdAvailability = debounce(async (id) => {
const res = await fetch(`/api/verify-id?val=${id}`);
return await res.json();
}, 500);
const idSchema = z.string().refine(
async (id) => {
const result = await checkIdAvailability(id);
return result.isAvailable;
},
{ message: "この ID は使用できません" }
);
2. 並列検証の実行
デフォルトでは検証は順次実行されますが、superRefine を使うことで複数の非同期チェックを並列化できます。
const accountSchema = z.object({
handleId: z.string(),
contactEmail: z.string().email()
}).superRefine(async (data, ctx) => {
const [idRes, mailRes] = await Promise.all([
fetch(`/api/verify-id?val=${data.handleId}`).then(r => r.json()),
fetch(`/api/verify-mail?addr=${data.contactEmail}`).then(r => r.json())
]);
if (!idRes.isAvailable) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "この ID は使用できません",
path: ["handleId"]
});
}
if (mailRes.isRegistered) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "このメールアドレスは登録済みです",
path: ["contactEmail"]
});
}
});
3. React Hook Form との連携
React エコシステムでは、@hookform/resolvers を介して Zod スキーマを簡単に統合できます。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
function SignupComponent() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({
resolver: zodResolver(signupSchema)
});
const onFormSubmit = async (data) => {
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(data)
});
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<input {...register("handleId")} />
{errors.handleId && <span className="error">{errors.handleId.message}</span>}
<input {...register("contactEmail")} />
{errors.contactEmail && <span className="error">{errors.contactEmail.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "確認中..." : "登録する"}
</button>
</form>
);
}
エラーハンドリングとデバッグ
Zod は詳細なエラーオブジェクトを生成するため、問題箇所の特定が容易です。
try {
await membershipSchema.parseAsync(data);
} catch (err) {
if (err instanceof z.ZodError) {
console.log("検証エラー:", err.errors);
const uiErrors = err.errors.reduce((acc, curr) => {
const key = curr.path.join('.');
acc[key] = curr.message;
return acc;
}, {});
console.log("UI 用エラー:", uiErrors);
} else {
console.log("システムエラー:", err.message);
}
}
デバッグ時には、例外を投げない safeParseAsync の使用を推奨します。これにより、検証結果オブジェクトから直接エラー情報を取得できます。
プロジェクト構造の設計
検証ロジックを管理しやすいよう、以下のようなディレクトリ構成を採用することをお勧めします。
src/
├── schemas/
│ ├── account.ts # アカウント関連検証
│ ├── item.ts # 商品関連検証
│ └── shared.ts # 共通検証ルール
├── utils/
│ └── validation.ts # 検証支援関数
└── hooks/
└── useValidator.ts # 検証用カスタムフック
スキーマファイルの例
// src/schemas/account.ts
import { z } from "zod";
import { verifyId, verifyMail } from "../api/checkers";
export const signupSchema = z.object({
handleId: z.string()
.min(3, "ID は 3 文字以上")
.max(20, "ID は 20 文字以下")
.refine(verifyId, { message: "この ID は使用できません" }),
contactEmail: z.string()
.email("有効なメールアドレスを入力してください")
.refine(verifyMail, { message: "このメールアドレスは登録済みです" }),
passPhrase: z.string()
.min(8, "パスワードは 8 文字以上")
.refine(val => /[A-Z]/.test(val), "大文字を含めてください")
.refine(val => /[0-9]/.test(val), "数字を含めてください")
});
export const signinSchema = z.object({
contactEmail: z.string().email("有効なメールアドレスを入力してください"),
passPhrase: z.string().min(8, "パスワードは 8 文字以上")
});