PapaParse×ZodでCSVバリデーションをスマートに
はじめに
ストリーツ株式会社の@hanamaです。
業務システム開発案件のあるある要件第5位(私調べ)に「CSVファイルをアップロードしたい」というものがあります。ファイルのアップロードだけなら<input type='file' />
を使うことで簡単に実装できますが、その後のバリデーション処理は結構面倒です。
みなさんは普段、そのバリデーションをいつ行っていますか? CSVファイルをストレージにアップロードしてからバッチ処理で実行するやり方もあるかもしれませんが、ユーザーにすぐにフィードバックを返せるよう、できればクライアントサイドで実行したいです。
PapaParseとZodを使ったクライアントサイドバリデーション
今回は、PapaParse と Zod を使って、クライアントサイドでCSVをバリデーションを行ってみました。以下ではNext.jsでの利用を前提とします。
PapaParseはJavaScript用の高速なCSVパーサーで、Zodは型安全なバリデーションライブラリです。これらを組み合わせることで、手軽にCSVのバリデーションを実装できました。
ちなみにPapaParseにはReact用のラッパーライブラリであるreact-papaparse もあるのですが、ほとんどの型がany
だったりして少々使いにくいです。リファクタリングのPRも出されているようですが、1年以上経過した今でもまだマージされていません。(2024年6月10日時点)
カスタムフックの実装
CSVのパースとバリデーションをただの関数として定義しても良いのですが、今回はカスタムフックでの実装が良さそうです。
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つです。
- パーサークラスを返す関数にすることで、
Papa.parse
に渡すoption
を固定できる。 - CSVに期待する形式をZodスキーマで定義するため、型安全なバリデーションが可能。
- エラーは内部で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を実装することもできます。ぜひ、プロジェクトに取り入れてみてください。
宣伝
弊社では、メディアのための生成AIサービス「apnea」を提供しています。
取材情報をもとにして記事の執筆支援を行うため、ハルシネーションリスクを軽減しながら、短時間で高品質な記事を作成することが可能です。この記事も、apneaの助けを借りながら執筆しています。
一緒に働く仲間も募集しています。興味のある方はぜひ弊社採用情報をご確認ください。Next.jsのApp RouterやVercel AI SDKなどの最新技術を使いながら、情報流通を活性化させ、ユニークな文化を育むことを目指しています。
Discussion