【TypeScript×関数型】まとめてエラーを捕まえる!neverthrowで実現するスマートなCSV検証
はじめに
CSVデータの検証においては、数多くのエラーが同時発生する可能性があります。たとえば「ある行のIDが数値ではない」「日付が無効」「必須項目が空欄」など、複数セルで同時に問題が起こりえるのがCSVの難しいところです。
JavaScriptの例外処理では、標準的な例外処理手法であるtry-catchをネストしてエラーを拾う方法がよく使われてきましたが、このアプローチには多くの問題が潜んでいます。
そこで、今回は TypeScript の関数型スタイルでのエラーハンドリングを可能にするライブラリ neverthrow を活用し、複数のバリデーション結果をまとめて扱う方法を紹介します。
本記事の内容は以下の通りです。
- CSVの検証時に起きるエラーをどのように集約するか
-
try-catch
による従来の方法の問題点 - neverthrow を使った関数型スタイルのエラーハンドリングのメリット
CSVのフォーマット概要と今回の例
まずは、どのようなCSVを想定しているか、簡単に例を挙げてみます。仮に以下のようなカラムを含むCSVを考えましょう。
ID | 日付 | 数量 |
---|---|---|
1 | 2023-01-01 | 10 |
2 | 2023/01/02 | 5 |
-3 | 2023-01-03 | ten |
4 | 20 | |
999 | 2023-13-01 | -10 |
- ID: 正の整数であること
-
日付:
YYYY-MM-DD
形式で、存在する日付か - 数量: 0以上の数値
上記のように、ひとつの行の中でも複数のセルにエラーが含まれる場合が十分ありえます。
try-catch
を使ったエラーハンドリング
従来の まずは、try-catch
を使用してJavaScriptにおける標準的なエラーハンドリングがどのように動作するかを確認しましょう。
サンプルコード
function validateId(idStr: string): number {
if (!idStr) {
throw new Error("IDが空欄です");
}
const id = parseInt(idStr);
if (Number.isNaN(id) || id <= 0) {
throw new Error("IDが不正です");
}
return id;
}
function validateDate(dateStr: string): Date { /* 省略 */ }
function validateQuantity(quantityStr: string): number { /* 省略 */ }
function validateCSV(csv: string[][]): { id: number; date: Date; quantity: number; }[] {
return csv.map((row, i) => {
try {
const id = validateId(row[0]);
const date = validateDate(row[1]);
const quantity = validateQuantity(row[2]);
return { id, date, quantity };
} catch (e) {
throw new Error(`[行${i + 1}の検証失敗]: ${(e as Error).message}`);
}
});
}
// 例
// const csv = [
// ["1", "2023-01-01", "10"],
// ["2", "2023/01/02", "5"],
// ["-3", "2023-01-03", "ten"],
// ["4", "", "20"],
// ["999", "2023-13-01", "-10"],
// ];
// validateCSV(csv);
// -> Error: [行2の検証失敗]: IDが不正です
try-catch
を使ったエラーハンドリングの問題点
1. 複数のエラーをまとめて返す処理が複雑
上記サンプルコードでは、1つのセルでエラーが出るとすぐに throw
してしまうので、行内に複数エラーがあっても最初の1つだけが返されます。
これでは、ある行の全てのエラーを一度に返すことができないため、ユーザーは「1つずつエラーを修正していく」ことになります。
実行を続行して全セルのエラーを収集するには、try-catch
をネスト、エラーを集約するための変数を用意する工夫が必要です。
function validateCSV(csv: string[][]): { id: number; date: Date; quantity: number; }[] {
const allErrors: string[] = [];
return csv.map((row, i) => {
const rowErrors: string[] = [];
try {
try {
const id = validateId(row[0]);
} catch (e) {
rowErrors.push(`ID: ${(e as Error).message}`);
}
try {
const date = validateDate(row[1]);
} catch (e) {
rowErrors.push(`日付: ${(e as Error).message}`);
}
try {
const quantity = validateQuantity(row[2]);
} catch (e) {
rowErrors.push(`数量: ${(e as Error).message}`);
}
if (rowErrors.length > 0) {
throw new Error(`[行${i + 1}の検証失敗]: ${rowErrors.join(", ")}`);
}
} catch (e) {
allErrors.push((e as Error).message);
}
if (allErrors.length > 0) {
throw new Error(allErrors.join("\n"));
}
return { id, date, quantity };
});
}
しかしながら、このようなネストは可読性が悪くなります。
2. 関数のシグネチャからエラーがわからない
validateId
関数のシグネチャvalidateId(idStr: string): number
だけを見ても、エラーが発生した場合に Error
を throw
することがわかりません。
function validateCSV(csv: string[][]): { id: number; date: Date; quantity: number; }[] {
return csv.map((row, i) => {
const id = validateId(row[0]); // この関数がエラーをthrowすることがシグネチャからはわからない
// ...
});
}
このため、エラーが発生する可能性がある関数を使う際には、その関数の中身を確認しなければなりません。これは、関数の使い方を知らない他の開発者にとっては非常に不便です。
neverthrow
を使った関数型スタイルのエラーハンドリング
neverthrow
は、TypeScript における関数型のエラーハンドリングをサポートするライブラリです。
neverthrow
は、関数型プログラミングでよく用いられる Result
型を提供しており、これは Rust の Result
や Haskell の Either
と同様に「成功時(Ok
)」「失敗時(Err
)」の二つのケースを型で表現します。これにより、エラーを “例外” として投げるのではなく、“値” として明示的に扱い、安全に合成や伝搬ができる のが特徴です。さらに複数の Result
を合成して、どれか一つでも Err
であればまとめて Err
にするといった機能を備えており、複数エラーの集約にも向いています。
基本的な使い方
neverthrow
では、主に以下の型や関数を使います:
-
Result<T, E>
Ok<T>
かErr<E>
のいずれかを表す型 -
ok<T>(value: T)
成功時の値を包む -
err<E>(error: E)
失敗時の値(エラー)を包む -
Result.combine()
複数のResult
を一括合成し、どれか一つでもErr
ならErr
を返す
すべてOk
なら[T, T, ...]
のタプルを返す -
Result.combineWithAllErrors()
複数のResult
を一括合成し、すべてのErr
を返す
これらの他にも、Result
には map
, mapErr
, andThen
などの関数型のエラーハンドリングをサポートするメソッドが用意されています。GitHubもしくは以下の記事で詳しく解説されているので、ぜひ参考にしてください。
neverthrow
を使ったCSVバリデーションの具体例
セルごとのバリデーション関数を定義する
複数セルのエラーを「まとめて」返すために、まずはセル単位で Result
型を返す関数を定義してみましょう。
import { Result, ok, err } from "neverthrow";
const validateId = (idStr: string): Result<number, Error> => {
if (!idStr) {
return err(new Error("IDが空欄です"));
}
const id = parseInt(idStr, 10);
if (Number.isNaN(id) || id <= 0) {
return err(new Error("IDが不正です"));
}
return ok(id);
};
const validateDate = (dateStr: string): Result<Date, Error> => { /* 省略 */ };
const validateQuantity = (quantityStr: string): Result<number, Error> => { /* 省略 */ };
上記のように、バリデーションに失敗した場合に err(...)
、成功した場合に ok(...)
を返すだけの単純な実装です。呼び出し元は関数シグネチャだけを見てエラーが発生することがわかりますし、コンパイル時点でエラーハンドリングを強制することができます。
Result
を合成してまとめる
行単位で 1行分のセル(row: string[]
)を検証するときには、先ほど定義した複数のバリデーション関数の結果を合成します。複数の Result
をまとめるには、neverthrow
が提供する Result.combineWithAllErrors()
を使う方法が便利です。
type ValidatedRow = { id: number; date: Date; quantity: number };
const validateRow = (row: string[]): Result<ValidatedRow, Error[]> => {
const [id, date, quantity] = row;
const idResult = validateId(id);
const dateResult = validateDate(date);
const quantityResult = validateQuantity(quantity);
return Result.combineWithAllErrors([idResult, dateResult, quantityResult]).map(([id, date, quantity]) => ({ id, date, quantity }));
};
Result.combineWithAllErrors()
を使えば、
- バリデーションが成功した場合は
Ok
として整合性の取れた行データを返す - バリデーションに失敗した場合は、各セルのエラーを「ひとまとめにした配列」として返す
ことができます。
try-catch
を使った場合と比べて、ネストがなくなり可読性が向上していることがわかります。
CSVファイル全体の処理フロー
最後に、CSVファイル全体の処理フローを定義してみましょう。
type RowError = { row: number; errors: Error[] }; // 行を含めたエラー情報
const validateCSV = (csv: string[][]): Result<ValidatedRow[], RowError[]> => {
const rows = csv.map((row, i) => validateRow(row).mapErr(e => ({ row: i + 1, errors: e })));
return Result.combineWithAllErrors(rows);
};
こうすることで、どの行のどのセルがどんな原因で不正だったかをまとめて通知できます。
neverthrow
を使ったエラーハンドリングのメリットまとめ
-
ネストが減りコードがシンプル
各セルのバリデーションは独立した小さな関数で書き、最後にまとめるだけです。 -
シグネチャからエラーの存在が分かる
Result<T, E>
と書いておけば、呼び出し元はエラーが発生することを知ることができます。
また、コンパイル時点でエラーハンドリングを強制できるので、エラーを見逃すことがありません。 -
複数のエラーを合成しやすい
neverthrow
の合成機能によって複数セルのエラーをまとめるのが簡単です。
まとめ
CSV で多発しがちな「複数セルにわたるエラー」をまとめてハンドリングする場合、従来の try-catch
では「最初に見つかったエラーで即終了」「エラーの種類がシグネチャから分からない」「ネストが深くなる」という問題がありました。
一方、neverthrow を使うことで、
-
Result<T, E>
という型によりエラーを明示的に表現し、 - セル単位のバリデーションを独立させることでコードをシンプルに保ち、
-
Result.combine()
などで複数エラーをまとめて返す
といった関数型スタイルのエラーハンドリングを実現できます。これにより、可読性・安全性が向上し、複雑なバリデーションロジックを扱う場合にも対応しやすくなりました。
なお、本記事では割と手続的な実装をしていますが、より関数型スタイルに寄せてリファクタリングしていくことも十分に可能です。
また、本番運用では
- 大量行のCSVを処理するときのパフォーマンス
- 失敗行と成功行をどのように扱うか(部分更新 or 全件却下)
- エラーメッセージをどこで集約して、誰に通知するか
などの検討も必要ですが、neverthrow
はその基盤として非常に有用なライブラリです。
neverthrow
の他にも、fp-tsのように同様のエラーハンドリングを実現できる関数型プログラミングライブラリもあります。その中でも、neverthrow
は抽象度が高すぎず、Result
型のみを提供するため、初めて関数型プログラミングに触れる人にも扱いやすいと思います。
ぜひ、neverthrow
を用いて関数型スタイルのエラーハンドリングに挑戦してみてください。
参考
「物流の次を発明する」をミッションに物流のシェアリングプラットフォームを運営する、ハコベル株式会社 開発チームのテックブログです! 【エンジニア積極採用中】t.hacobell.com/blog/career
Discussion