Zod を用いた非同期フォーム検証の技術的アプローチ

Web アプリケーション開発において、ユーザー入力の検証は不可欠です。しかし、ユーザー名の重複確認やメール住所の実在確認など、サーバー側との通信を伴う検証邏輯は、従来の同期処理では対応が困難です。Zod を利用することで、TypeScript の型安全性を保ちつつ、非同期検証をスムーズに実装できます。

非同期検証が必要な場面

現代の Web サービスでは、以下のようなケースでバックエンドとの連携が必要になります。

  • アカウント ID の一意性確認
  • 登録済みメールアドレスの重複チェック
  • プロモーションコードの有効性検証
  • 外部 API を通じた住所情報の照合

これらの要件を満たすには、Zod が提供する非同期検証機能を活用するのが効果的です。

Zod 非同期検証の基礎

Zod では、非同期処理を含む検証のために parseAsyncsafeParseAsync の 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 文字以上")
});

タグ: zod TypeScript react-hook-form Form-Validation async-await

5月20日 04:54 投稿