⚖️

TypeScriptにResult型を導入するための妥協点はどこか?

に公開
  • 現実のアプリケーションで発生するすべてのエラー・例外をResult型に変換するのは非現実的
  • エラーハンドリングが不要なものはUnexpectedErrorとしてまとめてしまう

という現実的な落とし所を提案する記事です。

TypeScriptにResult型を導入したくなる理由

TypeScriptのエラーハンドリングは、try…catch文を使うのが基本です。tryブロック内でthrowされた例外はcatchブロックで捕捉されます。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/try...catch

try…catchによるエラーハンドリングには、以下の問題があります。

  • 例外がthrowされる可能性がある関数かどうかが型シグネチャに表れないため、呼び出し時にtry…catchが必要かどうかわからない
  • exceptionVarの型がunknown(設定によってはany)のため、エラーの種類に応じたハンドリングができない
    • try…catchを細かく切って個別にカスタムエラーを定義したとしても、エラーハンドリングの漏れをコンパイル時に検出できない
  • try…catchブロックが散在し、制御フローが不明瞭になる

これらの問題の解決策としてResult型への関心が高まっています。

https://zenn.dev/knowledgework/articles/7ff389c5fe8f06

https://zenn.dev/okunokentaro/articles/01jf78zf9dx7hkmkhs48mtyzat

Result型を使ってエラーハンドリングすることで、以下の恩恵が得られます。

  • エラーの可能性が型として表現される
  • エラーハンドリングの漏れをコンパイル時に検出できる

TypeScriptでResult型を使う際の課題

ポケモンAPIを呼び出してポケモンの情報を取得するgetPokemon関数を例に、TypeScriptでResult型を使う際の課題を説明します。

まず、TypeScriptでResult型を使うには、自作したりライブラリを利用したりする必要があります。TypeScriptの標準ライブラリにResult型は含まれません。当然、TypeScriptエコシステムの多くのライブラリやWeb APIはResult型を返すのではなく例外をthrowします。

const getPokemon = async (pokemonName: string) => {
  // ネットワークエラーが発生した場合に例外をthrowする
  const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${pokemonName}`);
  // JSONのパースに失敗した場合に例外をthrowする
  return await response.json() as Pokemon;
}

const pikachu = await getPokemon('pikachu');
console.log(pikachu);

例外がthrowされる可能性がある箇所でtry…catchし、カスタムエラーを定義してResult型に変換することで、例外がthrowされる可能性がある関数をResult型を返す関数に変換できます。

class NetworkError extends ErrorFactory({
  name: 'NetworkError',
  message: 'ネットワークエラーが発生しました',
}) {}
const fetchPokemon = async (pokemonName: string) => {
  try {
    const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${pokemonName}`);
    return Result.succeed(response);
  } catch(error) {
    return Result.fail(new NetworkError({ cause: error }));
  }
}

class ParseJsonError extends ErrorFactory({
  name: 'ParseJsonError',
  message: 'JSONのパースに失敗しました',
}) {}
const parsePokemon = async (response: Response) => {
  try {
    const pokemon = await response.json() as Pokemon;
    return Result.succeed(pokemon);
  } catch(error) {
    return Result.fail(new ParseJsonError({ cause: error }));
  }
}

これらの関数を組み合わせて、getPokemonがResult型を返すようにします。

type GetPokemonError = NetworkError | ParseJsonError;

const getPokemon = async (pokemonName: string): Result.ResultAsync<Pokemon, GetPokemonError> => {
  const fetchResult = await fetchPokemon(pokemonName);
  if (Result.isFailure(fetchResult)) {
    return Result.fail(fetchResult.error);
  }

  const parseResult = await parsePokemon(fetchResult.value);
  if (Result.isFailure(parseResult)) {
    return Result.fail(parseResult.error);
  }

  return Result.succeed(parseResult.value);
}

const result = await getPokemon('pikachu');
if (Result.isFailure(result)) {
  console.error(result.error);
} else {
  console.log(result.value);
}

また、例外がthrowされる可能性がある関数を変換するだけでなく、値の検査の結果をResult型として表現することで、エラーハンドリングの可能性を高めることもできます。

たとえばpokemonNameがユーザー入力由来の文字列である場合、文字列のバリデーション→ユーザーにフィードバックして修正を促すというハンドリングが必要そうです。また、HTTPステータスコードに基づくユーザーへのフィードバックもほしいです。これらの検査を行う関数とカスタムエラーを追加します。

class ValidationError extends ErrorFactory({
  name: 'ValidationError',
  message: ({ details }) => `バリデーションエラーが発生しました: ${details}`,
  fields: ErrorFactory.fields<{ details: string }>(),
}) {};
const validatePokemonName = (pokemonName: string) => {
  if (pokemonName === '') {
    return Result.fail(new ValidationError({ details: 'ポケモンの名前を入力してください'}));
  }
  return Result.succeed(pokemonName);
};

class HttpError extends ErrorFactory({
  name: 'HttpError',
  message: ({ status }) => `HTTPエラーが発生しました: ${status}`,
  fields: ErrorFactory.fields<{ status: number }>(),
}) {};
const checkHttpStatus = (response: Response) => {
  if (!response.ok) {
    return Result.fail(new HttpError({ status: response.status }));
  }
  return Result.succeed(response);
}

これらの検査をgetPokemon関数に追加します。

type GetPokemonError = NetworkError | ParseJsonError | ValidationError | HttpError;

const getPokemon = async (pokemonName: string): Result.ResultAsync<Pokemon, GetPokemonError> => {
  const validateResult = validatePokemonName(pokemonName);
  if (Result.isFailure(validateResult)) {
    return Result.fail(validateResult.error);
  }

  const fetchResult = await fetchPokemon(pokemonName);
  if (Result.isFailure(fetchResult)) {
    return Result.fail(fetchResult.error);
  }

  const checkResult = checkHttpStatus(fetchResult.value);
  if (Result.isFailure(checkResult)) {
    return Result.fail(checkResult.error);
  }

  const parseResult = await parsePokemon(checkResult.value);
  if (Result.isFailure(parseResult)) {
    return Result.fail(parseResult.error);
  }

  return Result.succeed(parseResult.value);
}

const result = await getPokemon('pikachu');
if (Result.isFailure(result)) {
  console.error(result.error);
} else {
  console.log(result.value);
}

getPokemon関数は、エラーの可能性が型として表現され、エラーハンドリングの漏れをコンパイル時に検出できるようになりました。

しかし、このアプローチは現実的でしょうか?

エラー・例外が発生する可能性のあるコードはアプリケーションのいたるところに存在します。そのすべての箇所でヌケモレなく前述の対応をすることは困難です。できたとしても、コードの開発・保守には非現実的なコストがかかるでしょう。

TypeScriptにResult型を導入するための妥協点はどこか?

前述の理由から、TypeScriptにResult型を導入するのは無意味であるといった意見を見かけることがあります。

たしかに、すべてのエラー・例外を個別のResult型に変換することは現実的ではありません。妥協点を見つける必要があります。

そもそもすべてのエラー・例外を個別のResult型に変換する必要はあるのでしょうか。個別のResult型にする意味があるエラー・例外と、そうでないエラー・例外とを区別できないでしょうか。

この疑問について考えることで、コードの可読性を保ちつつResult型の恩恵を享受できるアプローチが見えてきます。

エラーハンドリングが必要な例外とそうでない例外を区別する

すべてのエラー・例外を個別のResult型に変換する必要はありません。エラーハンドリングが必要なエラー・例外のみを個別のResult型で扱い、ハンドリングする必要がないエラー・例外はUnexpectedErrorのResult型としてまとめることで、現実的な開発・保守コストでResult型の恩恵を享受できます。

実装方法

個別のResult型にする意味がある/ないエラー・例外を区別するための判断基準とその実装方法を、getPokemon関数を例に説明します。

まず、定義したカスタムエラーを、エラーハンドリングが必要なものとそうでないものに分類します。

カスタムエラー エラーハンドリングが必要か 理由
ValidationError 必要 ユーザーにフィードバックして修正を促す必要がある
HttpError 必要 not foundであったり認証エラーであったりする場合は、ユーザーにフィードバックする必要がある
NetworkError 不要 具体的なエラーの内容をユーザーにフィードバックしない
ParseJsonError 不要 具体的なエラーの内容をユーザーにフィードバックしない

次に、ハンドリングが必要ないエラー・例外をまとめるためのUnexpectedErrorを定義します。

class UnexpectedError extends ErrorFactory({
  name: 'UnexpectedError',
  message: '予期しない例外が発生しました'
}) {}

最後に、以下の通りにgetPokemon関数を修正します。

  • 処理全体をtry…catchで囲む
  • 個別のハンドリングが必要なエラー(ValidationErrorHttpError)は個別のResult型を返す
  • ハンドリングが必要でないエラー(fetch()やjson()が失敗した場合のエラー)はUnexpectedErrorのResult型として返す
type GetPokemonError = ValidationError | HttpError | UnexpectedError;

const getPokemon = async (pokemonName: string): Result.ResultAsync<Pokemon, GetPokemonError> => {
  try {
    const validateResult = validatePokemonName(pokemonName)
    if (Result.isFailure(validateResult)) {
      return Result.fail(validateResult.error);
    }

    // ネットワークエラーの場合、UnexpectedError
    const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${pokemonName}`);

    const checkResult = checkHttpStatus(response)
    if (Result.isFailure(checkResult)) {
      return Result.fail(checkResult.error)
    }

    // JSONのパースエラーの場合、UnexpectedError
    const pokemon = await checkResult.value.json();

    return Result.succeed(pokemon);
  } catch(error) {
    return Result.fail(new UnexpectedError({ cause: error }));
  }
}

このアプローチの利点

エラーハンドリングが必要ないエラー・例外はUnexpectedErrorとしてまとめるアプローチには、すべてのエラー・例外を個別のResult型に変換するアプローチでは得られない、以下のような利点があります。

現実的な開発・保守コスト

呼び出し側でハンドリングしたいかどうかを判断基準にすれば、すべてのエラー・例外を個別のResult型に変換する必要がないため、現実的な開発・保守コストでResult型の恩恵を享受できます。

クリーンなスタックトレース

causeプロパティを使うことで、アプリケーションコードを直接指し示すエラーで元のエラーをラップできます。これにより、どこでエラーが発生したかが明確になり、デバッグが容易になります。

// エラーが発生した場合
console.error(result.error); // UnexpectedError - アプリケーションコードを指すスタックトレース
console.error(result.error.cause); // 元のエラー(fetch失敗やJSONパースエラーなど)の詳細情報

元のエラー(fetch()の失敗やJSONパースエラーなど)の詳細情報はcauseプロパティから参照できるため、必要に応じて根本原因を調査できます。

予期しないエラーの一元的な処理

UnexpectedErrorとしてまとめることで、予期しないエラーを一元的に処理できます。たとえば、予期しないエラーが発生した場合に、ログ収集サービスにエラーを送信する、といった共通処理をUnexpectedErrorのハンドリング箇所に集約できます。

if (result.error instanceof UnexpectedError) {
  // 予期しないエラーはすべてログ収集サービスに送信
  logger.error('Unexpected error occurred', { error: result.error });
  // ユーザーには汎用的なエラーメッセージを表示
  showErrorMessage('エラーが発生しました。時間をおいて再度お試しください。');
}

おまけ: そもそもResult型が必要かどうかを考える

本記事では、エラーハンドリングが不要なエラー・例外をUnexpectedErrorとしてまとめることで現実的なコストでResult型を導入する方法を紹介しました。

前提の話になってしまうのですが、この手法の導入について考える前に、まずそもそも自分のアプリケーションにResult型が必要なのかどうかを考えることはもっと重要な検討事項です。

Result型の導入には、以下のようなコストがかかります。

  • Result型のライブラリの学習コスト
  • カスタムエラークラスの定義・保守コスト
  • Result型を扱うためのコード記述の増加
  • チームメンバー全員への周知と理解の促進

これらのコストに見合う恩恵が得られるのは、以下のような特性を持つアプリケーションです。

  • 複雑なエラーハンドリングが必要
    • ユーザー入力のバリデーション、外部API呼び出し、データベース操作など、多様なエラーケースが存在する
    • エラーの種類に応じて異なるハンドリング(リトライ、ユーザーへのフィードバック、ログ記録など)が必要
  • 型安全性が重要
    • エラーハンドリングの漏れがビジネスロジックに影響を与える可能性がある
    • コンパイル時にエラーハンドリングの網羅性を保証したい

一方、以下のようなシンプルなアプリケーションでは、従来のtry…catchによるエラーハンドリングの方がコストパフォーマンスが高いかもしれません。

  • エラーハンドリングのパターンがシンプルで、ほとんどのエラーは同じように処理される
  • エラーの種類が少なく、個別のハンドリングが必要なケースがほとんどない
  • チームの規模が小さく、コードレビューでエラーハンドリングの漏れを十分に防げる

Result型を導入することが目的化してしまい、不要な複雑性を追加してしまわないよう注意しましょう。まずは自分のアプリケーションの特性を分析し、Result型の導入が本当に価値をもたらすのかを慎重に検討することが大切です。

GitHubで編集を提案
PrAha

Discussion