Movatterモバイル変換


[0]ホーム

URL:


PrAhaPrAha
PrAhaPublicationへの投稿
⚖️

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

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

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

!

ライブラリ固有の知識がなくても理解できるように、最小限のAPIのみを使用しています。

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します。

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

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

classNetworkErrorextendsErrorFactory({  name:'NetworkError',  message:'ネットワークエラーが発生しました',}){}constfetchPokemon=async(pokemonName:string)=>{try{const response=awaitfetch(`https://pokeapi.co/api/v2/pokemon/${pokemonName}`);return Result.succeed(response);}catch(error){return Result.fail(newNetworkError({ cause: error}));}}classParseJsonErrorextendsErrorFactory({  name:'ParseJsonError',  message:'JSONのパースに失敗しました',}){}constparsePokemon=async(response: Response)=>{try{const pokemon=await response.json()as Pokemon;return Result.succeed(pokemon);}catch(error){return Result.fail(newParseJsonError({ cause: error}));}}

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

typeGetPokemonError= NetworkError| ParseJsonError;const getPokemon=async(pokemonName:string): Result.ResultAsync<Pokemon, GetPokemonError>=>{const fetchResult=awaitfetchPokemon(pokemonName);if(Result.isFailure(fetchResult)){return Result.fail(fetchResult.error);}const parseResult=awaitparsePokemon(fetchResult.value);if(Result.isFailure(parseResult)){return Result.fail(parseResult.error);}return Result.succeed(parseResult.value);}const result=awaitgetPokemon('pikachu');if(Result.isFailure(result)){console.error(result.error);}else{console.log(result.value);}

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

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

classValidationErrorextendsErrorFactory({  name:'ValidationError',message:({ details})=>`バリデーションエラーが発生しました:${details}`,  fields: ErrorFactory.fields<{ details:string}>(),}){};constvalidatePokemonName=(pokemonName:string)=>{if(pokemonName===''){return Result.fail(newValidationError({ details:'ポケモンの名前を入力してください'}));}return Result.succeed(pokemonName);};classHttpErrorextendsErrorFactory({  name:'HttpError',message:({ status})=>`HTTPエラーが発生しました:${status}`,  fields: ErrorFactory.fields<{ status:number}>(),}){};constcheckHttpStatus=(response: Response)=>{if(!response.ok){return Result.fail(newHttpError({ status: response.status}));}return Result.succeed(response);}

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

typeGetPokemonError= 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=awaitfetchPokemon(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=awaitparsePokemon(checkResult.value);if(Result.isFailure(parseResult)){return Result.fail(parseResult.error);}return Result.succeed(parseResult.value);}const result=awaitgetPokemon('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を定義します。

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

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

  • 処理全体をtry…catchで囲む
  • 個別のハンドリングが必要なエラー(ValidationErrorHttpError)は個別のResult型を返す
  • ハンドリングが必要でないエラー(fetch()やjson()が失敗した場合のエラー)はUnexpectedErrorのResult型として返す
typeGetPokemonError= 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);}// ネットワークエラーの場合、UnexpectedErrorconst response=awaitfetch(`https://pokeapi.co/api/v2/pokemon/${pokemonName}`);const checkResult=checkHttpStatus(response)if(Result.isFailure(checkResult)){return Result.fail(checkResult.error)}// JSONのパースエラーの場合、UnexpectedErrorconst pokemon=await checkResult.value.json();return Result.succeed(pokemon);}catch(error){return Result.fail(newUnexpectedError({ cause: error}));}}
!

ボイラープレートが多いと感じるのであれば、ライブラリが提供するAPIを活用することを検討しましょう。

byethrowのAPIをフル活用した場合の`getPokemon`
typeGetPokemonError= ValidationError| HttpError| UnexpectedError;const getPokemon=(pokemonName:string): Result.ResultAsync<Pokemon, GetPokemonError>=>{return Result.pipe(    Result.succeed(pokemonName),    Result.andThrough(validatePokemonName),    Result.andThen((pokemonName)=> Result.try({      immediate:true,try:async()=>awaitfetch(`https://pokeapi.co/api/v2/pokemon/${pokemonName}`),catch:(error)=>newUnexpectedError({ cause: error}),})),    Result.andThrough(checkHttpStatus),    Result.andThen((response)=> Result.try({      immediate:true,try:async()=>await response.json(),catch:(error)=>newUnexpectedError({ 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.errorinstanceofUnexpectedError){// 予期しないエラーはすべてログ収集サービスに送信  logger.error('Unexpected error occurred',{ error: result.error});// ユーザーには汎用的なエラーメッセージを表示showErrorMessage('エラーが発生しました。時間をおいて再度お試しください。');}

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

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

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

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

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

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

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

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

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

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

PrAha により固定

\ PrAha Challenge 第11期生 募集中!/

  • 3人1組で実践的な課題に挑戦
  • 設計・レビュー・議論を通して考える力を磨く

→詳しくはこちらへ!

☆☆☆

\ エンジニア募集中! /

  • ものづくりが好きな優しいギークが集まってる
  • フルリモート・フルフレックス
  • 年収618〜1,069万円(正社員の場合)
  • 充実した福利厚生

→詳しくはこちらへ!

GitHubで編集を提案
ゲントク

webアプリケーションエンジニア

PrAha

株式会社PrAhaでは、株式会社アガルートのサービス「アガルートアカデミー」の開発、自社サービス「PrAha Challenge」の運営、スタートアップに特化したデザインと受託開発を行なっています。一緒に働いてくれる方を血眼になって探しています。

バッジを贈って著者を応援しよう

バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。

あいや - aiya000あいや - aiya000

とても興味深いです!
以下のようなパターンの連続は

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

biFlatMap: <E1, E2, A, B>(m: Result<A, E1>, k: (a: A) => Result<B, E2>) => Result<B, E2>
のようなものがあれば

const checkResult=biFlatMap(fetchResult, checkHttpStaus)

とかできそうなので、pipe()とうまくできればもっと可能性あるかも⋯

あとは
handle: <A>(f: () => A) => (() => Result<A, Error>) // 勝手にtry-catchして、catchすればnew Error(e)を勝手にするやつ。実際はfの引数に可変長型引数使ったりできそう
があれば「とりあえず何が投げられるのか、そもそも例外が投げられるのかもわからない」場合にこれを使えばいいので

const someFunc=handle(()=>{// 普通の関数定義})

みたいに関数を定義するようにして

const result: Result<Ok, Error>=someFunc()// someFunc内でhandleに渡された関数で例外が起これば、自動でResult.fail()されて、resultに入る

とかすると、開始地点でErrorという曖昧性が生まれるものの、後続では、自分達でthrow(fail)したいときにbiFlatMapして、全て包み込むことはできそうかも⋯!?
とか考えてみました👀

1
ゲントクゲントク

biFlatMaphandleのようなアイデア、ありがとうございます!そうした観点を提示いただけるのはとても参考になります。

byethrowでも似たAPIとしてandThentryのような関数を用意しています。
記事では「ライブラリ固有の知識がなくても読めること」を重視していたために限られた関数しか使っていませんが、こうして別の切り口で見てもらえるのは嬉しいです。

もし興味があればドキュメントも覗いてみてください!


[8]ページ先頭

©2009-2025 Movatter.jp