T-CREATOR

Zod で配列・オブジェクトを安全に扱うバリデーションテクニック

Zod で配列・オブジェクトを安全に扱うバリデーションテクニック

Web 開発において、JavaScript や TypeScript で配列やオブジェクトを扱う際、実行時エラーに悩まされた経験はありませんか? TypeScript の型チェックだけでは防げないランタイムエラーを、Zod を活用することで確実に防ぐことができます。

今回は、実際の開発現場でよく遭遇する配列・オブジェクト操作の問題を解決する Zod のバリデーションテクニックをご紹介します。 実践的なコード例とともに、安全で堅牢なアプリケーションを構築する方法を学んでいきましょう。

背景

TypeScript 型安全性の限界

TypeScript は静的型チェックにより開発時の型安全性を提供してくれますが、実行時のデータに対しては無力です。 特に外部 API からのレスポンスやユーザー入力データなど、実行時に動的に取得されるデータは型情報が保証されません。

以下のような問題が発生することがあります。

typescript// TypeScriptでは問題なくコンパイルされるが...
interface User {
  id: number;
  name: string;
  email: string;
}

// APIから取得したデータが期待通りの型であるとは限らない
const userData: User = await fetchUserData();
console.log(userData.name.toUpperCase()); // 実行時エラーの可能性

実行時バリデーションの必要性

現代の Web アプリケーションでは、様々なデータソースから情報を取得します。 これらのデータが常に期待通りの形式で提供される保証はありません。

typescript// 外部APIからの不安定なデータ例
const apiResponse = {
  users: null, // 配列のはずがnull
  metadata: {
    // countプロパティが欠如
  },
};

実行時バリデーションを実装することで、このような予期しないデータ構造によるエラーを防ぐことができるのです。

配列・オブジェクトで発生しがちな問題

実際の開発現場では以下のような問題に直面することが多いでしょう。

typescript// よくある問題例
const processUserList = (users: any[]) => {
  // 配列が空の場合やnullの場合の考慮不足
  return users.map((user) => user.name.toUpperCase()); // TypeError: Cannot read property 'name' of null
};

// オブジェクトのプロパティ存在チェック不足
const getUserEmail = (user: any) => {
  return user.contact.email; // TypeError: Cannot read property 'email' of undefined
};

これらの問題を図で整理すると以下のようになります。

mermaidflowchart TD
  data[外部データ] --> check{型チェック}
  check -->|TypeScriptのみ| compile[コンパイル時チェック]
  check -->|Zod使用| runtime[実行時チェック]

  compile --> risk[実行時エラーリスク]
  runtime --> safe[型安全な処理]

  risk --> error1[undefined プロパティアクセス]
  risk --> error2[null参照エラー]
  risk --> error3[型不一致による処理エラー]

  safe --> validate[データ検証]
  safe --> transform[型変換・正規化]
  safe --> process[安全な処理実行]

課題

未定義プロパティへのアクセス

オブジェクトのプロパティにアクセスする際、そのプロパティが存在しない場合に発生するエラーは非常に一般的です。

typescript// 問題のあるコード例
interface UserProfile {
  personalInfo: {
    firstName: string;
    lastName: string;
    address?: {
      street: string;
      city: string;
    };
  };
}

const displayUserAddress = (profile: UserProfile) => {
  // addressがundefinedの場合にエラーが発生
  return `${profile.personalInfo.address.street}, ${profile.personalInfo.address.city}`;
};

この問題は、オプショナルプロパティや深くネストしたオブジェクト構造で特に顕著に現れます。

配列の要素型不一致

配列を扱う際、要素の型が期待通りでない場合に発生する問題も深刻です。

typescript// 配列要素の型が統一されていない場合
const processNumbers = (numbers: number[]) => {
  return numbers.map((num) => num * 2); // 文字列が混じっているとNaNが発生
};

// 実際のデータ
const mixedArray = [1, '2', 3, null, 4]; // 型が混在
processNumbers(mixedArray as number[]); // 危険なキャスト

ネストしたオブジェクトの検証漏れ

複雑な構造を持つオブジェクトでは、深い階層での検証が漏れがちです。

typescript// 複雑なデータ構造の例
interface ApiResponse {
  status: string;
  data: {
    users: Array<{
      id: number;
      profile: {
        personal: {
          name: string;
          contacts: {
            emails: string[];
            phones: string[];
          };
        };
        preferences: {
          notifications: boolean;
          privacy: {
            shareData: boolean;
          };
        };
      };
    }>;
  };
}

このような構造では、各階層での型検証を適切に行うことが困難になります。

以下の図は、これらの課題の関連性を示しています。

mermaidflowchart LR
  subgraph problems[よくある問題]
    prop[未定義プロパティ]
    array[配列要素型不一致]
    nest[ネスト構造検証漏れ]
  end

  subgraph causes[原因]
    runtime[実行時データの不確実性]
    complex[複雑なデータ構造]
    validation[検証処理の不備]
  end

  subgraph results[結果]
    crash[アプリケーションクラッシュ]
    data_corruption[データ破損]
    user_error[ユーザーエクスペリエンス低下]
  end

  causes --> problems
  problems --> results

解決策

Zod による基本的な配列検証

Zod を使用することで、配列の要素型を厳密にチェックし、安全な操作を保証できます。

typescriptimport { z } from 'zod';

// 基本的な配列スキーマの定義
const NumberArraySchema = z.array(z.number());
const StringArraySchema = z.array(z.string());

配列要素数の制限も簡単に設定できます。

typescript// 配列要素数の制限
const LimitedArraySchema = z
  .array(z.string())
  .min(1, '配列は少なくとも1つの要素が必要です')
  .max(10, '配列の要素数は10個までです');

実際の検証処理では以下のようになります。

typescriptconst validateNumberArray = (data: unknown) => {
  try {
    const validatedArray = NumberArraySchema.parse(data);
    return validatedArray; // 型安全な number[] が返される
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error('バリデーションエラー:', error.errors);
    }
    throw error;
  }
};

// 使用例
const processNumbers = (input: unknown) => {
  const numbers = validateNumberArray(input);
  // この時点で numbers は確実に number[] 型
  return numbers.map((num) => num * 2);
};

オブジェクトスキーマ定義

オブジェクトの構造を明確に定義し、プロパティの存在と型を検証します。

typescript// ユーザーオブジェクトのスキーマ定義
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z
    .string()
    .email('有効なメールアドレスを入力してください'),
  age: z.number().min(0).max(120),
  isActive: z.boolean().default(true),
});

オプショナルプロパティの定義も簡潔です。

typescript// オプショナルプロパティを含むスキーマ
const UserProfileSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  phone: z.string().optional(), // オプショナル
  address: z
    .object({
      street: z.string(),
      city: z.string(),
      postalCode: z
        .string()
        .regex(
          /^\d{3}-\d{4}$/,
          '郵便番号は###-####の形式で入力してください'
        ),
    })
    .optional(),
});

type UserProfile = z.infer<typeof UserProfileSchema>;

ネストした構造の安全な検証

複雑なネスト構造も段階的にスキーマを定義することで安全に処理できます。

typescript// 段階的なスキーマ定義
const ContactSchema = z.object({
  emails: z.array(z.string().email()),
  phones: z.array(z.string()),
});

const PersonalInfoSchema = z.object({
  name: z.string(),
  contacts: ContactSchema,
});

const PreferencesSchema = z.object({
  notifications: z.boolean(),
  privacy: z.object({
    shareData: z.boolean(),
  }),
});

これらを組み合わせて最終的な構造を定義します。

typescriptconst UserCompleteSchema = z.object({
  id: z.number(),
  profile: z.object({
    personal: PersonalInfoSchema,
    preferences: PreferencesSchema,
  }),
});

const ApiResponseSchema = z.object({
  status: z.string(),
  data: z.object({
    users: z.array(UserCompleteSchema),
  }),
});

以下の図は、Zod による解決策のアプローチを示しています。

mermaidflowchart TD
  input[入力データ] --> zodSchema[Zodスキーマ]
  zodSchema --> validation{バリデーション}

  validation -->|成功| validated[検証済みデータ]
  validation -->|失敗| error[エラーハンドリング]

  validated --> process[安全な処理]
  error --> retry[リトライ処理]
  error --> fallback[フォールバック処理]
  error --> log[エラーログ]

  subgraph zodFeatures[Zodの機能]
    transform[データ変換]
    typeInfer[型推論]
    errorMsg[詳細エラーメッセージ]
  end

  zodSchema --> zodFeatures

具体例

配列要素の型チェック実装

API から取得したユーザーリストを安全に処理する実装例をご紹介します。

typescriptimport { z } from 'zod';

// ユーザーデータのスキーマ定義
const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1, '名前は必須です'),
  email: z
    .string()
    .email('有効なメールアドレスを入力してください'),
  role: z.enum(['admin', 'user', 'guest']),
});

const UsersArraySchema = z.array(UserSchema);
type User = z.infer<typeof UserSchema>;

実際のバリデーション処理では、エラーハンドリングを含めた実装を行います。

typescriptconst processUserList = async (
  apiData: unknown
): Promise<User[]> => {
  try {
    // Zodによるバリデーション
    const validatedUsers = UsersArraySchema.parse(apiData);

    console.log(
      `${validatedUsers.length}人のユーザーを処理します`
    );
    return validatedUsers;
  } catch (error) {
    if (error instanceof z.ZodError) {
      // 詳細なエラー情報を表示
      error.errors.forEach((err, index) => {
        console.error(`エラー ${index + 1}:`, {
          path: err.path.join('.'),
          message: err.message,
          received: err.received,
        });
      });
    }
    throw new Error('ユーザーデータの形式が不正です');
  }
};

使用例とエラーケースの処理方法も見てみましょう。

typescript// API呼び出しとバリデーション
const fetchAndProcessUsers = async () => {
  try {
    const response = await fetch('/api/users');
    const rawData = await response.json();

    const users = await processUserList(rawData);

    // この時点で users は型安全な User[] として使用可能
    const adminUsers = users.filter(
      (user) => user.role === 'admin'
    );
    const userEmails = users.map((user) => user.email);

    return { users, adminUsers, userEmails };
  } catch (error) {
    console.error('ユーザー処理でエラーが発生:', error);
    // フォールバック処理
    return { users: [], adminUsers: [], userEmails: [] };
  }
};

オブジェクトのプロパティ検証

フォームデータの検証を例に、オブジェクトの安全な処理方法を説明します。

typescript// フォームデータのスキーマ定義
const ContactFormSchema = z.object({
  name: z
    .string()
    .min(1, 'お名前は必須です')
    .max(50, 'お名前は50文字以内で入力してください'),
  email: z
    .string()
    .email('有効なメールアドレスを入力してください'),
  phone: z
    .string()
    .regex(
      /^0\d{1,4}-\d{1,4}-\d{4}$/,
      '電話番号の形式が正しくありません'
    )
    .optional(),
  message: z
    .string()
    .min(10, 'メッセージは10文字以上で入力してください')
    .max(
      1000,
      'メッセージは1000文字以内で入力してください'
    ),
  agreeToTerms: z.boolean().refine((val) => val === true, {
    message: '利用規約への同意が必要です',
  }),
});

type ContactForm = z.infer<typeof ContactFormSchema>;

バリデーション関数の実装では、エラーメッセージを分かりやすく整形します。

typescriptconst validateContactForm = (formData: unknown) => {
  const result = ContactFormSchema.safeParse(formData);

  if (!result.success) {
    // エラーを項目別に整理
    const fieldErrors: Record<string, string> = {};

    result.error.errors.forEach((error) => {
      const fieldName = error.path[0] as string;
      if (!fieldErrors[fieldName]) {
        fieldErrors[fieldName] = error.message;
      }
    });

    return {
      success: false,
      errors: fieldErrors,
      data: null,
    };
  }

  return {
    success: true,
    errors: {},
    data: result.data,
  };
};

実際のフォーム処理では以下のように使用します。

typescriptconst handleFormSubmission = async (
  rawFormData: FormData
) => {
  // FormDataをオブジェクトに変換
  const formObject = Object.fromEntries(
    rawFormData.entries()
  );

  // チェックボックスのブール値変換
  formObject.agreeToTerms =
    formObject.agreeToTerms === 'on';

  const validationResult = validateContactForm(formObject);

  if (!validationResult.success) {
    // バリデーションエラーの場合
    return {
      status: 'error',
      message: 'フォームに入力エラーがあります',
      fieldErrors: validationResult.errors,
    };
  }

  // 検証済みデータを使用
  const contactData = validationResult.data;

  try {
    // データベースへの保存処理
    await saveContactData(contactData);

    return {
      status: 'success',
      message: 'お問い合わせを受付ました',
    };
  } catch (error) {
    return {
      status: 'error',
      message: 'サーバーエラーが発生しました',
    };
  }
};

複雑なデータ構造の検証

EC サイトの注文データのような複雑な構造を扱う例を見てみましょう。

typescript// 商品アイテムのスキーマ
const OrderItemSchema = z.object({
  productId: z.number(),
  productName: z.string(),
  quantity: z
    .number()
    .min(1, '数量は1以上である必要があります'),
  unitPrice: z.number().min(0),
  totalPrice: z.number().min(0),
});

// 配送先情報のスキーマ
const ShippingAddressSchema = z.object({
  name: z.string().min(1),
  postalCode: z.string().regex(/^\d{3}-\d{4}$/),
  prefecture: z.string(),
  city: z.string(),
  streetAddress: z.string(),
  building: z.string().optional(),
});

これらを組み合わせて注文全体のスキーマを定義します。

typescript// 注文データ全体のスキーマ
const OrderSchema = z
  .object({
    orderId: z
      .string()
      .uuid('注文IDの形式が正しくありません'),
    customerId: z.number(),
    orderDate: z
      .string()
      .datetime('日時の形式が正しくありません'),
    items: z
      .array(OrderItemSchema)
      .min(1, '注文には少なくとも1つの商品が必要です'),
    shippingAddress: ShippingAddressSchema,
    paymentMethod: z.enum([
      'credit_card',
      'bank_transfer',
      'cash_on_delivery',
    ]),
    totalAmount: z.number().min(0),
    status: z.enum([
      'pending',
      'confirmed',
      'shipped',
      'delivered',
      'cancelled',
    ]),
  })
  .refine(
    (data) => {
      // 合計金額の整合性チェック
      const calculatedTotal = data.items.reduce(
        (sum, item) => sum + item.totalPrice,
        0
      );
      return (
        Math.abs(calculatedTotal - data.totalAmount) < 0.01
      );
    },
    {
      message: '合計金額が商品価格の合計と一致しません',
      path: ['totalAmount'],
    }
  );

type Order = z.infer<typeof OrderSchema>;

エラーハンドリングパターン

実用的なエラーハンドリングのパターンをいくつか紹介します。

typescript// パターン1: 段階的バリデーション
const validateOrderWithFallback = (data: unknown) => {
  // まず基本的な構造をチェック
  const basicStructure = z.object({
    orderId: z.string(),
    customerId: z.number(),
    items: z.array(z.unknown()),
  });

  try {
    basicStructure.parse(data);
  } catch {
    return {
      success: false,
      error: '基本的なデータ構造が不正です',
    };
  }

  // 詳細なバリデーションを実行
  const result = OrderSchema.safeParse(data);

  if (!result.success) {
    return {
      success: false,
      error: '詳細バリデーションに失敗',
      details: result.error.errors,
    };
  }

  return { success: true, data: result.data };
};
typescript// パターン2: 部分的な修復処理
const validateAndRepairOrder = (data: unknown) => {
  const result = OrderSchema.safeParse(data);

  if (result.success) {
    return {
      success: true,
      data: result.data,
      repaired: false,
    };
  }

  // 修復可能なエラーをチェック
  let repairedData = { ...(data as any) };
  let wasRepaired = false;

  result.error.errors.forEach((error) => {
    if (
      error.path.includes('orderDate') &&
      error.code === 'invalid_string'
    ) {
      // 日付形式の修復
      repairedData.orderDate = new Date().toISOString();
      wasRepaired = true;
    }

    if (
      error.path.includes('status') &&
      error.code === 'invalid_enum_value'
    ) {
      // ステータスのデフォルト設定
      repairedData.status = 'pending';
      wasRepaired = true;
    }
  });

  if (wasRepaired) {
    const repairResult =
      OrderSchema.safeParse(repairedData);
    if (repairResult.success) {
      return {
        success: true,
        data: repairResult.data,
        repaired: true,
      };
    }
  }

  return { success: false, error: result.error };
};

以下の図は、複雑なデータ構造の検証フローを示しています。

mermaidsequenceDiagram
  participant Client as クライアント
  participant Validator as バリデーター
  participant Schema as Zodスキーマ
  participant DB as データベース

  Client ->> Validator: 注文データ送信
  Validator ->> Schema: 基本構造チェック
  Schema -->> Validator: 構造OK/NG

  alt 基本構造OK
    Validator ->> Schema: 詳細バリデーション
    Schema -->> Validator: 詳細チェック結果

    alt 詳細バリデーションOK
      Validator ->> DB: データ保存
      DB -->> Validator: 保存完了
      Validator -->> Client: 成功レスポンス
    else 詳細バリデーションNG
      Validator ->> Validator: 修復処理試行
      alt 修復成功
        Validator ->> DB: 修復データ保存
        DB -->> Validator: 保存完了
        Validator -->> Client: 修復完了レスポンス
      else 修復失敗
        Validator -->> Client: エラーレスポンス
      end
    end
  else 基本構造NG
    Validator -->> Client: 構造エラーレスポンス
  end

まとめ

Zod による安全な配列・オブジェクト操作のポイント

今回ご紹介した Zod を活用したバリデーションテクニックにより、以下のメリットを実現できます。

型安全性の向上 Zod のスキーマ定義により、TypeScript の静的型チェックと実行時バリデーションの両方を活用できます。 z.inferを使用することで、スキーマから自動的に型を生成し、コードの保守性も向上します。

エラーの早期発見 データの不整合や型不一致を実行時に早期発見することで、アプリケーションのクラッシュを防げます。 詳細なエラーメッセージにより、問題の特定と修正も効率的に行えるでしょう。

保守性の向上 スキーマを中心とした設計により、データ構造の変更時の影響範囲が明確になります。 段階的なスキーマ定義により、複雑な構造も理解しやすく保てるのです。

実践で活用する際の重要なポイント

段階的な導入 既存プロジェクトに Zod を導入する際は、重要度の高い API エンドポイントやフォーム処理から段階的に適用することをお勧めします。

エラーハンドリングの充実
バリデーションエラーが発生した場合の適切な処理フローを設計し、ユーザーエクスペリエンスを損なわないよう配慮することが大切です。

パフォーマンスへの配慮 大量のデータを処理する場合は、バリデーション処理のパフォーマンスに注意を払い、必要に応じて部分的バリデーションや非同期処理を検討しましょう。

Zod を活用することで、より安全で信頼性の高い Web アプリケーションを構築できます。 今回ご紹介したテクニックを参考に、ぜひ実際のプロジェクトで Zod を活用してみてください。

関連リンク