🚯

【TypeScript×関数型】まとめてエラーを捕まえる!neverthrowで実現するスマートなCSV検証

2025/01/14に公開

はじめに

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 だけを見ても、エラーが発生した場合に Errorthrow することがわかりません。

function validateCSV(csv: string[][]): { id: number; date: Date; quantity: number; }[] {
  return csv.map((row, i) => {
    const id = validateId(row[0]); // この関数がエラーをthrowすることがシグネチャからはわからない
    // ...
  });
}

このため、エラーが発生する可能性がある関数を使う際には、その関数の中身を確認しなければなりません。これは、関数の使い方を知らない他の開発者にとっては非常に不便です。

neverthrow を使った関数型スタイルのエラーハンドリング

neverthrow は、TypeScript における関数型のエラーハンドリングをサポートするライブラリです。
https://github.com/supermacro/neverthrow

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もしくは以下の記事で詳しく解説されているので、ぜひ参考にしてください。
https://zenn.dev/akineko/articles/3d366bb1fb26f8

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を用いて関数型スタイルのエラーハンドリングに挑戦してみてください。

参考

https://speakerdeck.com/kakehashi/strike-a-balance-between-correctness-and-efficiency-with-fp-ts

https://speakerdeck.com/naoya/typescript-niyoru-graphql-batukuendokai-fa

https://speakerdeck.com/yuitosato/railway-oriented-programming-in-onion-architecture-by-kotlin-result

GitHubで編集を提案
Hacobell Developers Blog

Discussion