Zod を使って CSV からの入力データをバリデーションする

こんにちは、バックエンドエンジニアの日下です。

CSV から JSON へ変換するスクリプトを、TypeScript で実装する機会がありました。
今回は、CSV のデータのバリデーションに Zod を使った話をします。

スクリプトの目的

システム間のデータ連携が目的です。
連携元のシステムから CSV 出力されたデータを、連携先のシステムで利用する JSON へ変換します。

また、JSON への変換以外にも以下の要件があります。

  • CSV のデータをバリデーションする
    連携先のシステムで利用できるデータであることを保証するために、バリデーションを実行します。
  • バリデーション失敗時に、日本語のエラーメッセージを表示する
    スクリプトの実行は業務担当のエンドユーザーが行うため、日本語のエラーメッセージを表示します。

CSV の読み込み

元データとなる CSV の読み込みは、csv-parse を使用しました。

import fs from 'node:fs/promises';
import { parse } from 'csv-parse/sync';

// CSV ファイルの内容を InputData の配列に変換する
const inputs: InputData[] = parse(await fs.readFile(csv), {
  // ヘッダー行を除いて読み込む
  from_line: 2,
  // 1行の各要素のプロパティ名を指定する
  columns: ['numbers', 'name'],
});

CSV の各行の内容を、オブジェクトの配列に変換しています。

Zod とは

CSV から読み込んだオブジェクトのバリデーションには、Zod を使用しました。
Zod は、TypeScript ファーストのスキーマ宣言およびバリデーションライブリです。

zod.dev

なぜ Zod なのか?

宣言的な記述によりデータの情報を一覧できる

Zod ではオブジェクトのスキーマを宣言し、各プロパティごとのバリデーション・変換処理を記述できます。
これにより、オブジェクトに対して、以下の情報を一覧できます。

  • どのようなプロパティが必要なのか
  • 各プロパティは、どのような入力であるべきなのか
  • 各プロパティが、どのように変換されるのか
import { z } from 'zod'

// numbers, name プロパティを持つオブジェクト
const InputDataSchema = z.object({
  // 期待する入力データ例: "1,2,3"
  // バリデーション後に得られるデータ: [1, 2, 3]
  numbers: z
    .string() // 文字列であることを検証する
    .min(1) // 1文字以上であることを検証する
    .transform((value) => value.split(',')) // カンマ区切りの文字列を配列に変換する
    .pipe(z.array(z.coerce.number().positive())), // 配列の各要素を数値に変換し、正の数であることを検証する
  name: z.string().min(1),
});

各プロパティに対するバリデーションが宣言的に書けるため、宣言したスキーマから必要な入力データが想像しやすいと考えています。
また、同時に変換処理も記述できるため、バリデーション同様にどのようなデータになるかもスキーマから把握しやすいです。

関連ツールが豊富である

Zod は関連ツールが豊富で、ドキュメントでも紹介されています。

今回の開発でも、以下2つのツールを利用しました。

  • zod-validation-error
    エラーメッセージのカスタマイズに使用しました。
  • zod-i18n-map
    エラーメッセージの日本語化に使用しました。

関連ツールを使用することで、要件にあったバリデーションを簡単に実現できるのも Zod の魅力です。

他ツールとの比較

Zod と同じくスキーマ宣言・バリデーションできるライブラリとして、Valibot があります。
今回は、以下 2 点の理由から Zod を採用しました。

  1. 使用事例や関連ツールが豊富であるから
    Zod の方が先発であるため、使用事例や関連ツールを多く見つけられました。
    同じバリデーションを実行する目的であれば、参考にできる事例・ツールを多く見つけられる方がスムーズに開発できると考えました。
  2. バンドルサイズを小さくする必要性が低かったから
    Valibot の方がバンドルサイズは小さいことが謳われていますが、今回はあまり重要視しませんでした。
    なぜなら、作成したスクリプトはサーバー上でビルド・実行するため、バンドルサイズを小さくする必要性が低かったからです。

CSV 用のエラーメッセージを組み立てる

CSV から読み取った内容はオブジェクトの配列になるため、バリデーションの実行で CSV 特有の事情を意識することはありませんでした。
しかし、CSV のバリデーションを行うために、エラーメッセージの組み立てで 2 点工夫したため紹介します。

どの行でエラーが発生したか把握できるようにする

なぜなら、CSV のどの行でエラーが発生したか把握できなければ、修正できないからです。
そのため、エラーメッセージに CSV の行番号を含めるようにしています。

今回、あらかじめ CSV から読み込んだオブジェクトに、行番号を記録するようにしました。

// Zod で定義したスキーマから型を抽出し、行番号のプロパティを追加する
type InputData = z.input<typeof InputDataSchema> & {
  lineNo: number;
};

const inputs: InputData[] = // CSV の読み込み処理

// 行番号を記録する
for (const [index, input] of inputs.entries()) {
  // ヘッダー行をスキップした、2行目以降の行番号で記録する
  input.lineNo = index + 2;
}

バリデーションエラー時は、記録していた行番号をエラーメッセージへ含めるようエラーを組み立てます。
エラーの組み立てには、zod-validation-error の fromZodError を使用しました。

import { ZodError } from 'zod';
import { fromZodError } from 'zod-validation-error';

// CSV の各行の入力内容のバリデーション
for (const input of inputs) {
  try {
    InputDataSchema.parse(input);
  } catch (err) {
    // エラーメッセージを組み立てる
    const validationError = fromZodError(err as ZodError, {
      // 1つ目のエラーメッセージの先頭に行番号を表示する
      prefix: `${input.lineNo}行目`,
      // 2つ目以降のエラーメッセージは改行し、1行目と同様に行番号を表示する
      issueSeparator: `\n${input.lineNo}行目: `,
      // エラーが発生したプロパティ名をエラーメッセージのサフィックスに含めない
      // なぜなら、日本語でエラーを表示するため、英語のプロパティ名は表示しないから
      includePath: false,
    });
    // エラーを配列の validationErrors に追加する
    validationErrors.push(validationError);
  }
}

仮に CSV の 3 行目に 2 つのエラーが存在した場合、以下のようなエラーメッセージを得られます。

3行目: <1つ目のエラー内容>
3行目: <2つ目のエラー内容>

行中のどの要素でエラーが発生したか把握できるようにする

なぜなら、CSV の 1 行には複数の要素が含まれているからです。
どの要素でエラーが発生したか把握できなければ、修正できません。

今回は、日本語訳のために使用した zod-i18n-map の handlePath の機能を使い、エラーメッセージに要素名を含めました。

import { init } from 'i18next';
import { makeZodI18nMap } from 'zod-i18n-map';

// 日本語訳を設定する
await init({
  lng: 'ja',
  resources: {
    ja: {
      zod: {
        errors: {
          too_small: {
            string: {
              inclusive_with_path:
                '{{path}}は{{minimum}}文字以上の文字列である必要があります。',
            },
          },
        },
      },
      inputData: {
        numbers: '数値',
        name: '名前',
      },
    },
  },
});

// CSV の各行の入力内容のバリデーション
for (const input of inputs) {
  try {
    InputDataSchema.parse(input, {
      // zod-i18n-map を設定する
      errorMap: makeZodI18nMap({
        // 使用する日本語訳のネームスペースを指定する
        ns: ['zod', 'inputData'],
      }),
    });
  } catch (err) {
    // エラーメッセージの組み立て
  }
}

name に 1 文字以上の文字列であるバリデーションを設定している場合、name が 0 文字の時は以下のエラーメッセージが得られます。
名前は1文字以上の文字列である必要があります。

まとめ

Zod を使って CSV に対するバリデーションを実施し、システム間のデータの連携先へ不備のない JSON を提供できました。
また、CSV 用のエラーメッセージの組み立ても Zod の関連ツールを使用することで、簡易に実現できました。

We are hiring

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。
カジュアル面談も行っています。
お気軽にご連絡ください!

筆者は普段 Ruby を使って、業務では初めて TypeScript を使いました。
このように、普段使っていない言語へ挑戦する機会もあります!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com