という現実的な落とし所を提案する記事です。
!ライブラリ固有の知識がなくても理解できるように、最小限のAPIのみを使用しています。
TypeScriptのエラーハンドリングは、try…catch文を使うのが基本です。tryブロック内でthrowされた例外はcatchブロックで捕捉されます。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/try...catch
try…catchによるエラーハンドリングには、以下の問題があります。
exceptionVarの型がunknown(設定によってはany)のため、エラーの種類に応じたハンドリングができないこれらの問題の解決策としてResult型への関心が高まっています。
https://zenn.dev/knowledgework/articles/7ff389c5fe8f06
https://zenn.dev/okunokentaro/articles/01jf78zf9dx7hkmkhs48mtyzat
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型を導入するのは無意味であるといった意見を見かけることがあります。
たしかに、すべてのエラー・例外を個別のResult型に変換することは現実的ではありません。妥協点を見つける必要があります。
そもそもすべてのエラー・例外を個別のResult型に変換する必要はあるのでしょうか。個別のResult型にする意味があるエラー・例外と、そうでないエラー・例外とを区別できないでしょうか。
この疑問について考えることで、コードの可読性を保ちつつResult型の恩恵を享受できるアプローチが見えてきます。
すべてのエラー・例外を個別のResult型に変換する必要はありません。エラーハンドリングが必要なエラー・例外のみを個別のResult型で扱い、ハンドリングする必要がないエラー・例外はUnexpectedErrorのResult型としてまとめることで、現実的な開発・保守コストでResult型の恩恵を享受できます。
個別のResult型にする意味がある/ないエラー・例外を区別するための判断基準とその実装方法を、getPokemon関数を例に説明します。
まず、定義したカスタムエラーを、エラーハンドリングが必要なものとそうでないものに分類します。
| カスタムエラー | エラーハンドリングが必要か | 理由 |
|---|---|---|
ValidationError | 必要 | ユーザーにフィードバックして修正を促す必要がある |
HttpError | 必要 | not foundであったり認証エラーであったりする場合は、ユーザーにフィードバックする必要がある |
NetworkError | 不要 | 具体的なエラーの内容をユーザーにフィードバックしない |
ParseJsonError | 不要 | 具体的なエラーの内容をユーザーにフィードバックしない |
次に、ハンドリングが必要ないエラー・例外をまとめるためのUnexpectedErrorを定義します。
classUnexpectedErrorextendsErrorFactory({ name:'UnexpectedError', message:'予期しない例外が発生しました'}){}最後に、以下の通りにgetPokemon関数を修正します。
ValidationError、HttpError)は個別のResult型を返す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を活用することを検討しましょう。
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('エラーが発生しました。時間をおいて再度お試しください。');}本記事では、エラーハンドリングが不要なエラー・例外をUnexpectedErrorとしてまとめることで現実的なコストでResult型を導入する方法を紹介しました。
前提の話になってしまうのですが、この手法の導入について考える前に、まずそもそも自分のアプリケーションにResult型が必要なのかどうかを考えることはもっと重要な検討事項です。
Result型の導入には、以下のようなコストがかかります。
これらのコストに見合う恩恵が得られるのは、以下のような特性を持つアプリケーションです。
一方、以下のようなシンプルなアプリケーションでは、従来のtry…catchによるエラーハンドリングの方がコストパフォーマンスが高いかもしれません。
Result型を導入することが目的化してしまい、不要な複雑性を追加してしまわないよう注意しましょう。まずは自分のアプリケーションの特性を分析し、Result型の導入が本当に価値をもたらすのかを慎重に検討することが大切です。
\ PrAha Challenge 第11期生 募集中!/
☆☆☆
\ エンジニア募集中! /
バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。
とても興味深いです!
以下のようなパターンの連続は
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して、全て包み込むことはできそうかも⋯!?
とか考えてみました👀