T-CREATOR

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

【入門】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 が抜けている!
};

この問題により、以下のような事態が発生します:

  1. 型定義の変更がバリデーション処理に反映されない
  2. 新しいフィールド追加時の更新漏れ
  3. リファクタリング時の不整合

バリデーション処理の課題を図で整理してみましょう。

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 アプリケーションを構築できるようになります。まずは小さなフォームから始めて、段階的に複雑な実装に挑戦してみてください。

関連リンク