T-CREATOR

Zod で CSV/TSV インポートを安全に処理:パース → 検証 → 差分レポート

Zod で CSV/TSV インポートを安全に処理:パース → 検証 → 差分レポート

外部から CSV や TSV ファイルをインポートする機能を実装する際、データの検証やエラーハンドリングが不十分だと、システムに不正なデータが入り込んでしまいます。特に、ユーザーがアップロードしたファイルを直接データベースに投入する処理では、型安全性を担保しながら、どの行でエラーが発生したかを明確に報告する仕組みが求められますよね。

本記事では、TypeScript と Zod を使って、CSV/TSV ファイルのパース、スキーマ検証、差分レポート生成までを一貫して安全に処理する方法を解説いたします。実際のコードとともに、段階的に実装していきましょう。

背景

CSV/TSV インポートの一般的な流れ

Web アプリケーションで外部ファイルをインポートする際、以下のような流れが一般的です。

mermaidflowchart LR
  upload["ファイル<br/>アップロード"] --> parse["パース<br/>(CSV→JSON)"]
  parse --> validate["スキーマ<br/>検証"]
  validate --> report["差分<br/>レポート"]
  report --> import["DB<br/>インポート"]

図の要点:ファイルアップロード後、文字列をオブジェクト配列に変換し、スキーマ検証を経て、最終的にデータベースへ投入する流れを示しています。

この流れの中で特に重要なのが「スキーマ検証」のステップです。ユーザーが意図しない形式のデータをアップロードした場合、早期にエラーを検出し、どの行のどのフィールドに問題があるかを明示する必要があります。

TypeScript における型安全性の限界

TypeScript の型システムは、コンパイル時には強力な型チェックを提供しますが、実行時には型情報が失われてしまいます。そのため、外部から受け取る CSV データのような動的なデータに対しては、別途ランタイムバリデーションが必須となるのです。

課題

1. ランタイムでの型検証が困難

CSV ファイルから読み込んだデータは、すべて文字列として扱われます。数値型や日付型、必須フィールドなどのビジネスルールを適用するには、手動でバリデーションロジックを書く必要があり、コードが煩雑になりがちです。

2. エラー情報の詳細な報告が手間

単純なバリデーションでは「エラーが発生した」ことは分かっても、どの行のどのカラムで問題が起きたのかを特定するには、追加のコードが必要になります。ユーザーフレンドリーなエラーメッセージを生成するのは、意外と手間がかかるものです。

3. 差分レポートの生成が複雑

既存データと新規インポートデータを比較し、「追加されるもの」「更新されるもの」「削除されるもの」を抽出する処理は、ロジックが複雑になりやすく、バグの温床になりやすいですね。

以下の図は、これらの課題がどのように相互に関連しているかを示しています。

mermaidflowchart TD
  A["CSV データ"] --> B["すべて文字列"]
  B --> C["型変換が必要"]
  C --> D["バリデーション<br/>ロジック複雑化"]
  D --> E["エラー情報<br/>不明瞭"]
  E --> F["差分処理<br/>困難"]

図の要点:文字列データの型変換から始まる課題が、バリデーション、エラー報告、差分処理へと連鎖的に影響を及ぼす様子を表しています。

解決策

Zod によるスキーマ駆動のバリデーション

Zod は、TypeScript のための宣言的なスキーマバリデーションライブラリです。スキーマを定義するだけで、ランタイムでの型検証、型推論、詳細なエラーメッセージ生成が可能になります。

Zod を使うことで、以下の利点が得られます。

  • 型安全性: スキーマから TypeScript の型を自動推論できる
  • 明確なエラー: どのフィールドがどのルールに違反したかが分かる
  • 変換処理: 文字列から数値、日付などへの変換を自動化できる
  • 再利用性: 同じスキーマを API レスポンス検証などにも使える

処理フロー全体像

本記事で実装する処理フローは以下の通りです。

mermaidflowchart TB
  start["CSV/TSV<br/>ファイル"] --> parser["papaparse<br/>でパース"]
  parser --> raw["生データ<br/>(string[])"]
  raw --> zod["Zod スキーマ<br/>検証"]
  zod -->|成功| valid["検証済み<br/>データ"]
  zod -->|失敗| errors["エラー<br/>レポート"]
  valid --> diff["差分計算"]
  diff --> report["差分<br/>レポート"]
  errors --> end1["ユーザーへ<br/>エラー通知"]
  report --> end2["インポート<br/>実行"]

図の要点:パース後の生データを Zod で検証し、成功時は差分計算へ、失敗時はエラーレポート生成へと分岐する流れを示しています。

必要なパッケージのインストール

まず、必要なライブラリをインストールします。

bashyarn add zod papaparse
yarn add -D @types/papaparse
  • zod: スキーマ定義とバリデーション
  • papaparse: CSV/TSV のパース処理

具体例

ステップ 1: Zod スキーマの定義

まず、インポートするデータの構造を Zod スキーマで定義します。ここでは、ユーザー情報を含む CSV を例にしますね。

typescriptimport { z } from 'zod';

次に、基本的なユーザースキーマを定義します。

typescript// ユーザー情報のスキーマ定義
const UserSchema = z.object({
  id: z.string().min(1, 'ID は必須です'),
  name: z
    .string()
    .min(1, '名前は必須です')
    .max(100, '名前は100文字以内です'),
  email: z
    .string()
    .email('有効なメールアドレスを入力してください'),
  age: z
    .string()
    .transform((val) => parseInt(val, 10))
    .pipe(
      z
        .number()
        .min(0, '年齢は0以上です')
        .max(150, '年齢は150以下です')
    ),
  role: z.enum(['admin', 'user', 'guest'], {
    errorMap: () => ({
      message: 'role は admin, user, guest のいずれかです',
    }),
  }),
  isActive: z
    .string()
    .transform((val) => val.toLowerCase() === 'true')
    .pipe(z.boolean()),
});

このスキーマでは、文字列から数値やブール値への変換も自動的に行われます。transformpipe を使うことで、型変換とバリデーションを同時に実行できるのが便利ですね。

型の推論も自動的に行われます。

typescript// TypeScript の型を自動推論
type User = z.infer<typeof UserSchema>;

// User 型は以下のように推論される:
// {
//   id: string;
//   name: string;
//   email: string;
//   age: number;
//   role: "admin" | "user" | "guest";
//   isActive: boolean;
// }

ステップ 2: CSV/TSV パース処理

papaparse を使って、アップロードされたファイルをパースします。

typescriptimport Papa from 'papaparse';

ファイル読み込みとパース処理を行う関数を定義します。

typescript/**
 * CSV/TSV ファイルをパースする関数
 * @param file - アップロードされたファイル
 * @param delimiter - 区切り文字(',' または '\t')
 * @returns パース結果のオブジェクト配列
 */
async function parseFile(
  file: File,
  delimiter: ',' | '\t' = ','
): Promise<Record<string, string>[]> {
  return new Promise((resolve, reject) => {
    Papa.parse(file, {
      header: true, // 1行目をヘッダーとして扱う
      delimiter, // 区切り文字を指定
      skipEmptyLines: true, // 空行をスキップ
      complete: (results) => {
        resolve(results.data as Record<string, string>[]);
      },
      error: (error) => {
        reject(
          new Error(
            `ファイルのパースに失敗しました: ${error.message}`
          )
        );
      },
    });
  });
}

この関数は、CSV と TSV の両方に対応し、ヘッダー行を自動的にキーとして扱います。

ステップ 3: Zod によるバリデーション実行

パースしたデータを Zod で検証し、エラー情報を収集します。

typescript/**
 * バリデーション結果の型定義
 */
interface ValidationResult<T> {
  success: boolean; // 全体の成功・失敗
  data: T[]; // 検証済みの有効なデータ
  errors: ValidationError[]; // エラー情報の配列
}

interface ValidationError {
  row: number; // エラーが発生した行番号
  field: string; // エラーが発生したフィールド名
  message: string; // エラーメッセージ
  value: unknown; // 実際の値
}

次に、バリデーション処理の本体を実装します。

typescript/**
 * Zod スキーマで配列データを検証する関数
 * @param data - パースされた生データ
 * @param schema - Zod スキーマ
 * @returns バリデーション結果
 */
function validateData<T>(
  data: Record<string, unknown>[],
  schema: z.ZodType<T>
): ValidationResult<T> {
  const validData: T[] = [];
  const errors: ValidationError[] = [];

  // 各行をループ処理
  data.forEach((row, index) => {
    const result = schema.safeParse(row);

    if (result.success) {
      // 検証成功: 有効なデータとして保存
      validData.push(result.data);
    } else {
      // 検証失敗: エラー情報を収集
      result.error.errors.forEach((err) => {
        errors.push({
          row: index + 2, // ヘッダー行を考慮して +2
          field: err.path.join('.'),
          message: err.message,
          value: row[err.path[0] as string],
        });
      });
    }
  });

  return {
    success: errors.length === 0,
    data: validData,
    errors,
  };
}

safeParse を使うことで、エラーが発生しても例外をスローせず、結果オブジェクトとして受け取れます。これにより、全行を検証してからまとめてエラーレポートを生成できますね。

ステップ 4: エラーレポートの生成

検証結果からユーザーフレンドリーなエラーレポートを生成します。

typescript/**
 * エラーレポートを生成する関数
 * @param errors - バリデーションエラーの配列
 * @returns エラーレポートの文字列
 */
function generateErrorReport(
  errors: ValidationError[]
): string {
  if (errors.length === 0) {
    return 'エラーはありません。';
  }

  const report = [
    `${errors.length} 件のエラーが見つかりました:\n`,
  ];

  // 行番号でグループ化
  const errorsByRow = errors.reduce((acc, error) => {
    if (!acc[error.row]) {
      acc[error.row] = [];
    }
    acc[error.row].push(error);
    return acc;
  }, {} as Record<number, ValidationError[]>);

  // 各行のエラーを整形
  Object.entries(errorsByRow).forEach(
    ([row, rowErrors]) => {
      report.push(`\n【${row}行目】`);
      rowErrors.forEach((error) => {
        report.push(
          `  - ${error.field}: ${error.message} (値: ${error.value})`
        );
      });
    }
  );

  return report.join('\n');
}

このレポートにより、ユーザーはどの行のどのフィールドを修正すればよいか、一目で理解できますね。

ステップ 5: 差分計算処理

既存データと新規データを比較し、差分を抽出します。

typescript/**
 * 差分レポートの型定義
 */
interface DiffReport<T> {
  added: T[]; // 追加されるデータ
  updated: T[]; // 更新されるデータ
  deleted: T[]; // 削除されるデータ
  unchanged: T[]; // 変更なしのデータ
}

次に、差分計算のロジックを実装します。

typescript/**
 * 既存データと新規データの差分を計算する関数
 * @param existingData - 既存のデータ配列
 * @param newData - 新規データ配列
 * @param keyField - 一意キーとなるフィールド名
 * @returns 差分レポート
 */
function calculateDiff<T extends Record<string, unknown>>(
  existingData: T[],
  newData: T[],
  keyField: keyof T
): DiffReport<T> {
  // 既存データをマップに変換(高速検索のため)
  const existingMap = new Map(
    existingData.map((item) => [item[keyField], item])
  );
  const newMap = new Map(
    newData.map((item) => [item[keyField], item])
  );

  const added: T[] = [];
  const updated: T[] = [];
  const unchanged: T[] = [];

  // 新規データをループ
  newData.forEach((newItem) => {
    const key = newItem[keyField];
    const existingItem = existingMap.get(key);

    if (!existingItem) {
      // 既存データに存在しない → 追加
      added.push(newItem);
    } else if (
      JSON.stringify(existingItem) !==
      JSON.stringify(newItem)
    ) {
      // データが異なる → 更新
      updated.push(newItem);
    } else {
      // データが同じ → 変更なし
      unchanged.push(newItem);
    }
  });

  // 削除されたデータを抽出
  const deleted = existingData.filter(
    (item) => !newMap.has(item[keyField])
  );

  return { added, updated, deleted, unchanged };
}

Map を使うことで、キーによる検索を高速化しています。大量データの場合でも効率的に差分計算ができますね。

ステップ 6: 差分レポートの整形

差分結果を見やすくフォーマットします。

typescript/**
 * 差分レポートを文字列として整形する関数
 * @param diff - 差分レポート
 * @returns 整形された文字列
 */
function formatDiffReport<T>(diff: DiffReport<T>): string {
  const lines = ['=== インポート差分レポート ===\n'];

  lines.push(`追加: ${diff.added.length} 件`);
  lines.push(`更新: ${diff.updated.length} 件`);
  lines.push(`削除: ${diff.deleted.length} 件`);
  lines.push(`変更なし: ${diff.unchanged.length} 件\n`);

  if (diff.added.length > 0) {
    lines.push('【追加されるデータ】');
    diff.added.forEach((item, index) => {
      lines.push(`  ${index + 1}. ${JSON.stringify(item)}`);
    });
    lines.push('');
  }

  if (diff.updated.length > 0) {
    lines.push('【更新されるデータ】');
    diff.updated.forEach((item, index) => {
      lines.push(`  ${index + 1}. ${JSON.stringify(item)}`);
    });
    lines.push('');
  }

  if (diff.deleted.length > 0) {
    lines.push('【削除されるデータ】');
    diff.deleted.forEach((item, index) => {
      lines.push(`  ${index + 1}. ${JSON.stringify(item)}`);
    });
  }

  return lines.join('\n');
}

ステップ 7: 全体を統合した処理フロー

これまでの関数を組み合わせた、メイン処理を実装します。

typescript/**
 * CSV/TSV インポートの全体処理
 * @param file - アップロードされたファイル
 * @param existingData - 既存のデータ
 * @param delimiter - 区切り文字
 * @returns 処理結果
 */
async function importCsvTsv(
  file: File,
  existingData: User[],
  delimiter: ',' | '\t' = ','
): Promise<{
  success: boolean;
  errorReport?: string;
  diffReport?: string;
  validData?: User[];
}> {
  try {
    // 1. ファイルをパース
    const rawData = await parseFile(file, delimiter);

    // 2. Zod で検証
    const validation = validateData(rawData, UserSchema);

    // 3. エラーがあればレポートを返す
    if (!validation.success) {
      return {
        success: false,
        errorReport: generateErrorReport(validation.errors),
      };
    }

    // 4. 差分を計算
    const diff = calculateDiff(
      existingData,
      validation.data,
      'id'
    );

    // 5. 差分レポートを生成
    const diffReport = formatDiffReport(diff);

    return {
      success: true,
      diffReport,
      validData: validation.data,
    };
  } catch (error) {
    return {
      success: false,
      errorReport: `処理中にエラーが発生しました: ${
        error instanceof Error
          ? error.message
          : '不明なエラー'
      }`,
    };
  }
}

この関数は、パースからレポート生成までを一貫して処理し、エラー時には詳細な情報を返します。

ステップ 8: React コンポーネントでの利用例

実際の UI での使用例を示します。

typescriptimport React, { useState } from 'react';

ファイルアップロード用のコンポーネントを実装します。

typescript/**
 * CSV/TSV インポートコンポーネント
 */
export const CsvImporter: React.FC = () => {
  const [result, setResult] = useState<string>('');
  const [isLoading, setIsLoading] = useState(false);

  // 既存データ(本来は API から取得)
  const existingUsers: User[] = [
    {
      id: '001',
      name: '山田太郎',
      email: 'yamada@example.com',
      age: 30,
      role: 'admin',
      isActive: true,
    },
  ];

  const handleFileUpload = async (
    event: React.ChangeEvent<HTMLInputElement>
  ) => {
    const file = event.target.files?.[0];
    if (!file) return;

    setIsLoading(true);
    setResult('');

    // 拡張子から区切り文字を判定
    const delimiter = file.name.endsWith('.tsv')
      ? '\t'
      : ',';

    // インポート処理を実行
    const importResult = await importCsvTsv(
      file,
      existingUsers,
      delimiter
    );

    if (importResult.success) {
      setResult(importResult.diffReport || '');
    } else {
      setResult(
        importResult.errorReport || 'エラーが発生しました'
      );
    }

    setIsLoading(false);
  };

  return (
    <div>
      <h2>CSV/TSV インポート</h2>
      <input
        type='file'
        accept='.csv,.tsv'
        onChange={handleFileUpload}
        disabled={isLoading}
      />
      {isLoading && <p>処理中...</p>}
      {result && (
        <pre
          style={{
            background: '#f5f5f5',
            padding: '1rem',
            borderRadius: '4px',
          }}
        >
          {result}
        </pre>
      )}
    </div>
  );
};

このコンポーネントでは、ファイル選択時に自動的にインポート処理が実行され、結果がリアルタイムで表示されます。

エラーハンドリングの強化

より詳細なエラー分類を行うため、カスタムエラークラスを定義します。

typescript/**
 * カスタムエラークラス
 */
class CsvImportError extends Error {
  constructor(
    public code: string,
    message: string,
    public details?: unknown
  ) {
    super(message);
    this.name = 'CsvImportError';
  }
}

エラーコードを定義します。

typescript/**
 * エラーコードの定義
 */
const ERROR_CODES = {
  PARSE_ERROR: 'PARSE_ERROR', // パースエラー
  VALIDATION_ERROR: 'VALIDATION_ERROR', // バリデーションエラー
  FILE_TOO_LARGE: 'FILE_TOO_LARGE', // ファイルサイズ超過
  INVALID_FORMAT: 'INVALID_FORMAT', // フォーマット不正
} as const;

エラーハンドリングを強化した処理を実装します。

typescript/**
 * エラーハンドリングを強化したインポート処理
 */
async function importCsvTsvWithErrorHandling(
  file: File,
  existingData: User[],
  delimiter: ',' | '\t' = ','
): Promise<{
  success: boolean;
  errorCode?: string;
  errorReport?: string;
  diffReport?: string;
  validData?: User[];
}> {
  try {
    // ファイルサイズチェック(10MB まで)
    if (file.size > 10 * 1024 * 1024) {
      throw new CsvImportError(
        ERROR_CODES.FILE_TOO_LARGE,
        'ファイルサイズは10MB以下にしてください',
        { size: file.size }
      );
    }

    const rawData = await parseFile(file, delimiter);

    if (rawData.length === 0) {
      throw new CsvImportError(
        ERROR_CODES.INVALID_FORMAT,
        'データが空です。ヘッダー行とデータ行を確認してください'
      );
    }

    const validation = validateData(rawData, UserSchema);

    if (!validation.success) {
      throw new CsvImportError(
        ERROR_CODES.VALIDATION_ERROR,
        'バリデーションエラー',
        validation.errors
      );
    }

    const diff = calculateDiff(
      existingData,
      validation.data,
      'id'
    );
    const diffReport = formatDiffReport(diff);

    return {
      success: true,
      diffReport,
      validData: validation.data,
    };
  } catch (error) {
    if (error instanceof CsvImportError) {
      return {
        success: false,
        errorCode: error.code,
        errorReport:
          error.code === ERROR_CODES.VALIDATION_ERROR
            ? generateErrorReport(
                error.details as ValidationError[]
              )
            : error.message,
      };
    }

    return {
      success: false,
      errorCode: 'UNKNOWN_ERROR',
      errorReport: `予期しないエラーが発生しました: ${
        error instanceof Error
          ? error.message
          : '不明なエラー'
      }`,
    };
  }
}

カスタムエラーを使うことで、エラーの種類に応じた適切な処理が可能になります。

パフォーマンス最適化

大量データを扱う場合は、バッチ処理やストリーミングを検討します。

typescript/**
 * バッチ処理で大量データを検証する関数
 * @param data - パースされた生データ
 * @param schema - Zod スキーマ
 * @param batchSize - バッチサイズ(デフォルト: 1000)
 * @returns バリデーション結果
 */
async function validateDataInBatches<T>(
  data: Record<string, unknown>[],
  schema: z.ZodType<T>,
  batchSize: number = 1000
): Promise<ValidationResult<T>> {
  const validData: T[] = [];
  const errors: ValidationError[] = [];

  // データをバッチに分割
  for (let i = 0; i < data.length; i += batchSize) {
    const batch = data.slice(i, i + batchSize);

    // 各バッチを検証(非同期で待機してUIをブロックしない)
    await new Promise((resolve) => setTimeout(resolve, 0));

    batch.forEach((row, batchIndex) => {
      const result = schema.safeParse(row);
      const actualIndex = i + batchIndex;

      if (result.success) {
        validData.push(result.data);
      } else {
        result.error.errors.forEach((err) => {
          errors.push({
            row: actualIndex + 2,
            field: err.path.join('.'),
            message: err.message,
            value: row[err.path[0] as string],
          });
        });
      }
    });
  }

  return {
    success: errors.length === 0,
    data: validData,
    errors,
  };
}

バッチ処理により、UI がブロックされずにスムーズな体験を提供できますね。

テストコードの例

最後に、ユニットテストの例を示します。

typescriptimport { describe, test, expect } from 'vitest';

バリデーション処理のテストを実装します。

typescriptdescribe('CSV インポート処理', () => {
  test('正常なデータは検証に成功する', () => {
    const testData = [
      {
        id: '001',
        name: 'テストユーザー',
        email: 'test@example.com',
        age: '25',
        role: 'user',
        isActive: 'true',
      },
    ];

    const result = validateData(testData, UserSchema);

    expect(result.success).toBe(true);
    expect(result.data).toHaveLength(1);
    expect(result.errors).toHaveLength(0);
    expect(result.data[0].age).toBe(25); // 文字列→数値変換の確認
    expect(result.data[0].isActive).toBe(true); // 文字列→真偽値変換の確認
  });

  test('不正なメールアドレスはエラーになる', () => {
    const testData = [
      {
        id: '001',
        name: 'テストユーザー',
        email: 'invalid-email', // 不正なメール
        age: '25',
        role: 'user',
        isActive: 'true',
      },
    ];

    const result = validateData(testData, UserSchema);

    expect(result.success).toBe(false);
    expect(result.errors).toHaveLength(1);
    expect(result.errors[0].field).toBe('email');
    expect(result.errors[0].message).toContain(
      '有効なメールアドレス'
    );
  });

  test('差分計算が正しく動作する', () => {
    const existing: User[] = [
      {
        id: '001',
        name: '既存ユーザー',
        email: 'existing@example.com',
        age: 30,
        role: 'admin',
        isActive: true,
      },
    ];

    const newData: User[] = [
      {
        id: '001',
        name: '更新ユーザー', // 名前が変更
        email: 'existing@example.com',
        age: 30,
        role: 'admin',
        isActive: true,
      },
      {
        id: '002',
        name: '新規ユーザー',
        email: 'new@example.com',
        age: 25,
        role: 'user',
        isActive: true,
      },
    ];

    const diff = calculateDiff(existing, newData, 'id');

    expect(diff.added).toHaveLength(1); // 1件追加
    expect(diff.updated).toHaveLength(1); // 1件更新
    expect(diff.deleted).toHaveLength(0); // 削除なし
  });
});

テストを書くことで、リファクタリングや機能追加時の安全性が格段に向上します。

まとめ

本記事では、Zod を使った CSV/TSV インポート処理の実装方法を、パースから検証、差分レポート生成まで、段階的に解説しました。

重要なポイントをまとめますと、以下のようになります。

#ポイント効果
1Zod でスキーマ定義ランタイム検証と型推論を同時実現
2safeParse の活用例外を使わず全行検証が可能
3transform と pipe文字列から適切な型への自動変換
4詳細なエラーレポート行番号・フィールド名・値を明示
5差分計算既存データとの比較で変更内容を可視化

Zod を導入することで、手動で書いていた煩雑なバリデーションロジックが、宣言的でメンテナンスしやすいコードに生まれ変わります。特に、型安全性を保ちながら、ユーザーフレンドリーなエラーメッセージを自動生成できる点が大きな魅力ですね。

また、差分レポート機能により、ユーザーは「どのデータが変更されるのか」を事前に確認できるため、誤操作を防ぎ、安心してインポート操作を実行できるようになります。

今回紹介した実装パターンは、CSV/TSV 以外の外部データ連携でも応用できますので、ぜひプロジェクトに取り入れてみてください。

関連リンク