🗂️

PapaParse×ZodでCSVバリデーションをスマートに

2024/06/10に公開

はじめに

業務システム開発案件のあるある要件第5位(私調べ)に「CSVファイルをアップロードしたい」というものがあります。ファイルのアップロードだけなら<input type='file' />を使うことで簡単に実装できますが、その後のバリデーション処理は結構面倒です。

みなさんは普段、そのバリデーションをいつ行っていますか? CSVファイルをストレージにアップロードしてからバッチ処理で実行するやり方もあるかもしれませんが、ユーザーにすぐにフィードバックを返せるよう、できればクライアントサイドで実行したいです。

PapaParseとZodを使ったクライアントサイドバリデーション

今回は、PapaParseZod を使って、クライアントサイドでCSVをバリデーションを行ってみました。以下ではNext.jsでの利用を前提とします。

PapaParseはJavaScript用の高速なCSVパーサーで、Zodは型安全なバリデーションライブラリです。これらを組み合わせることで、手軽にCSVのバリデーションを実装できました。

ちなみにPapaParseにはReact用のラッパーライブラリであるreact-papaparse もあるのですが、ほとんどの型がanyだったりして少々使いにくいです。リファクタリングのPRも出されているようですが、1年以上経過した今でもまだマージされていません。(2024年6月10日時点)

カスタムフックの実装

CSVのパースとバリデーションをただの関数として定義しても良いのですが、今回はカスタムフックでの実装が良さそうです。

use-csv-parser.ts
export function useCsvParser<T extends z.ZodArray<z.ZodObject<z.ZodRawShape>>>(options: CsvParserOptions<T>) {
  // エラーメッセージのカスタマイズ
  // 詳しくはhttps://github.com/colinhacks/zod/blob/main/ERROR_HANDLING.md#customizing-errors-with-zoderrormapを参照
  useEffect(() => {
    z.setErrorMap(customErrorMap);
  }, []);

  const [error, setError] = useState<string | undefined>(undefined);
  const resetError = () => setError(undefined);

  return { parser: new CsvParser(options, setError), error, resetError };
}

ポイントは以下の3つです。

  1. パーサークラスを返す関数にすることで、Papa.parseに渡すoptionを固定できる。
  2. CSVに期待する形式をZodスキーマで定義するため、型安全なバリデーションが可能。
  3. エラーは内部でStateとして保持しているので、利用側では文字列としてJSXに埋め込めばOK。エラーをリセットするための関数resetErrorも提供。

パーサークラスの実装

上のカスタムフックで参照しているパーサークラスCsvParserの実装は以下のようになります。

interface CsvParserOptions<T extends z.ZodArray<z.ZodObject<z.ZodRawShape>>> {
  schema: T;
  transformHeader?: (header: string) => string;
  transformValues?: (value: string, header: string) => any;
  onValidationSuccess?: (data: z.infer<T>) => void;
}

class CsvParser<T extends z.ZodArray<z.ZodObject<z.ZodRawShape>>> {
  constructor(options: CsvParserOptions<T>, setError: (error: string | undefined) => void) {
    this.options = options;
    this.setError = setError;
  }

  private formatError(error: ZodError): string {
    return error.errors.reduce((acc, e) => {
      if (e.path) {
        return ${acc}${Number(e.path[0]) + 1}行目${e.path[1].toString().replace(/([A-Z])/g, (match) => _${match.toLowerCase()}`)}列: ${e.message}\n`;
      } else {
        return acc;
      }
    }, '');
  }

  parse(file: File): void {
    Papa.parse(file, {
      header: true,
      transformHeader: this.options.transformHeader,
      dynamicTyping: true, 
      transform: this.options.transformValues,
      complete: (result) => {
        const validationResult = this.options.schema.safeParse(result.data);
        if (validationResult.success) {
          this.options.onValidationSuccess?.(validationResult.data);
        } else {
          this.setError(this.formatError(validationResult.error));
        }
      },
    });
  }
}

parseメソッドでCSVファイルを解析し、 schemaオプションで指定されたZodスキーマでバリデーションを行います。バリデーションに失敗した場合は、formatErrorメソッドでエラーメッセージが整形された後、errrorが更新されます。

利用側のコード

利用側のコードは以下のようになります。


const { parser, error, resetError } = useCsvParser({
  schema: z.array(
    z.object({
      name: z.string(),
      email: z.string().email(),
      age: z.number().min(0).max(120),
    })
  ),
  onValidationSuccess: (data) => {
    // このタイミングで`data`は`schema`を満たす型安全なデータとして利用できる
    console.log(data);
  },
});

return (
  <>
    <input
      type="file"
      accept=".csv"
      onChange={(e) => {
        resetError();
        parser.parse(e.target.files![0]);
      }}
    />

    {error && <p style={{ color: 'red' }}>{error}</p>}
  </>
);

エラーメッセージはerror変数に格納されるので、それを表示するだけです。resetError関数を呼び出すことで、エラーメッセージをクリアできます。
onValidationSuccessオプションには、バリデーションに成功した場合の処理を記述できます。バリデーションに成功した型安全なデータを利用したロジックを記述できます。

まとめ

今回は、PapaParseとZodを使ってクライアントサイドでCSVのバリデーションを行う方法を紹介しました。カスタムフックを使うことで、エラーメッセージの管理やエラーのリセット処理を簡単に実装できます。また、Zodを使うことで型安全なバリデーションが可能です。

このカスタムフックをloadingを返すように拡張することで、CSVのパースが完了するまでボタンを無効化するなどのUIを実装することもできます。ぜひ、プロジェクトに取り入れてみてください。

Discussion