こんにちは。中途6ヶ月の生田です。
現在は GANMA! というサービスの管理画面開発でWebフロントエンドを担当しています。
今回は実業務で直面した以下の課題について、キャンプ1期間にどう対処していったかをご紹介します。
TypeScriptでエラー処理をいい感じにしたいが例外機構によってthrowされるオブジェクトがanyで辛いのでEither2のようなものを返り値として対処してみる
◇ 目次
◇ 前提
まず前提として GANMA! の管理画面で扱っている技術スタックを一部紹介します。
- TypeScript v3.9.3
- React v16.13.1
- Redux v4.0.5
- 非同期処理にはThunk
- Sentry
その他にも様々なライブラリに依存しているのですが、ここでは話す内容に関連したものだけの紹介とさせてもらいます。
◇ 課題
TypeScriptでcatch時の型がanyになってつらいので型安全に扱いたい
以下、課題の詳細を見ていきます。
TypeScriptではcatch時におけるthrowされたデータはany
// --- throwしうる関数たち --- function throwableFunction() { const mojiretsu: any = 'GANMA!'; return mojiretsu.poyo.piyo; // Uncaught TypeError: Cannot read property 'piyo' of undefined } function rejectPromise() { return Promise.reject(new Error('失敗した')) } // --- 使う側 --- function catcher1() { try { console.log(throwableFunction()); } catch(e) { console.log(e.message); // ※1 } } function catcher2() { rejectPromise .then(result => { console.log(result); }) .catch(e => { console.log(e.message); // ※2 }); }
※1と※2で仮引数/識別子eはanyになります。
以下のような凡ミスは簡単に起こせます。
} catch(e) { dispatch(showDialog(`エラー: ${e.mesage}`)); // message を mesage と打ち間違えている // ダイアログには「エラー: undefined」が表示される }
「え、なんでError型になってくれんのや」と一瞬思うのですが、JSではどんなオブジェクトでもthrowできる、且つ実行環境に依存したエラーがthrowされ得るということから必然的にanyとなってしまいます。
(↓のIssueで話されているので興味があれば。
https://github.com/microsoft/TypeScript/issues/8677)
また、Promiseのcatchでこそrejectに渡すデータの型を用いたいものですがcatchに渡すコールバックはrejectが呼ばれたときだけでなく
Promiseのコールバック内部でエラーがthrowされたときも同様に呼ばれるため、先程と同じ理由でanyとなってしまいます。
ちなみに、現在beta版がリリースされているTypeScript 4.0ではcatch節の識別子にunknownを注釈可能になります。
詳細は公式のアナウンスに書かれているので見てみると良いかもしれません。
公式ブログ - Announcing TypeScript 4.0 Beta
◇ 方針
特にPromise.rejectではCustomErrorのようなErrorを拡張した型なども扱っていきたいので任意の型を扱えるようにしたいです。
案としては以下2つを考えていきます。
- 呼び出す側で型を指定する
- throwableな処理をラップして
Eitherのような返り値で表現する (今回採用したもの)
説明用設定
前提となる設定として以下を設けます。
- 非同期の通信処理を扱うケースで考える
- APIへのリクエストを行うメソッドは「リポジトリ(
Repository)」と呼ばれるクラスで定義する - リポジトリメソッドは
Thunk関数からよばれる
// リポジトリの例 class UserRepository { // ... find(id: string) { return fetch( `https://example.com/api/v1/user/${id}`, { /* options */ } ).then(res => return res.json(); ); } findAll() { // ... } // Thunk関数の例 const fetchUserThunk = (id: string) => async (dispatch: Dispatch) => { // リポジトリメソッド呼び出し const result = await userRepository .find(id) // 失敗時処理 .catch(e => { dispatch(showErrorModal(e.message)); }); // 成功時処理 dispatch(fetchUserSucceedActionCreator(result)) } }
案1. 呼び出す側で型を指定する
Thunk関数でcatchを書くときにガード節を用いる形です。
try { /* ... */ } catch(e) { const _e: unknown = e; if (e instanceof CustomError) { showModal(`${e.message}/HTTP Status Code: ${e.httpStatusCode}`); } else { throw e; } }
シンプルに解決するなら多分これになるんじゃないでしょうか。TypeScript 4.0からはunknown注釈も楽に出来るようになるらしいので↓のように書けて良さげな感じです。
// 4.0からこれが出来るようになる予定 } catch(e: unknown) { // ...
しかし、リポジトリメソッド側で型が定義されるわけではないので利用する側で都度リポジトリメソッドがどんなエラーを発生させるかを考慮して実装する必要が出てしまいます。
例えばリポジトリ共通でreject/throwしうるオブジェクトの型がA/B/Cの3つだった場合、
利用するThunk側では使うメソッドが何をthrowするかちゃんと考えて型ガードで対応したりする必要が出てきます。
(扱いやすい形に変換する関数があればよいのかもしれませんが、
リポジトリメソッド側で型を制限できるに越したことは無いと思います)
なのでやるとした場合にはreject/throwする場合は基本的に
Errorオブジェクトを用いるようにするなどの対応を行い、
型ガードをしやすい形で進めることになると思います。
案2. throwableな処理をラップしてEitherのような返り値で表現する
これは少々複雑になりますが、やりたいことを一言でいうと以下のものになります。
リポジトリメソッドの返り値の型で成功|失敗を表現する
Scalaで言うところのEither<T, U>みたいなものになる感じですかね。
というわけでTypeScriptでEitherのようなものを実現する方法も含めて考えていきます。
今回は案2を実装したわけですが、理由として
『 Errorを拡張したオブジェクトをThunk側から扱いたい→
リポジトリメソッド側で型が決定できたほうが都合が良い』
というのがありました。
◇ 実装編
1. TypeScriptでEitherのようなものを実現する
現状TypeScriptにEitherのようなものは組み込みで存在していません。
そのため、似たようなものを実装する必要があります。
今回はEitherのようなものとしてResultを実装しました。
(※ Scalaを少し触った時にEitherは便利だなぁと思った程度の知識量なので
「これがEitherだ!」と言い切る自信がない…)
要求をまとめると以下のようになります。
a. Result<T, U>のように書ける b. Result型は利用側で成功/失敗に応じて処理を分岐できる c. 処理を分岐する場合、Result型は適切に推論される
1-a. Result<T, U>のように書ける
これはジェネリック型エイリアスを作って対応できます。
type Result<T, U> = /* ??? */
1-b. Result型は利用側で成功/失敗に応じて処理を分岐できる
TypeScriptでは型情報を用いた分岐は出来ません。
(コンパイルで型情報を削いだJavaScriptコードになりますからね)
成功・失敗をクラスで表現してinstanceofで判別するか、
オブジェクト構造でどちらか判別できるようにするなどの方法が考えられます。
今回は後者で実装しました。これは実装後に前者の方法に気づいたためです😇
type Result<T, U> = Success<U> | Failure<T>; type Success<T> = T & { success: true; } type Failure<T> = T & { success: false; } const success = <T extends object>(obj: T): Success<T> => Object.assign(obj, { success: true as const }); const failure = <T extends object>(obj: T): Failure<T> => Object.assign(obj, { success: false as const }); const isSuccess = <T, U>(result: Result<T, U>): result is Success<U> => result.success;
NOTE: Resultの改善の余地について
現状は紹介したような作りになっていますが、今見直すと
『Tのsuccessプロパティと競合する可能性がある』
『Tにオブジェクトしか指定できない』
という問題が有るため、
type Success<T> = { _tag: 'Success'; result: T; // いいプロパティ名が思い浮かばなかった… }
のような被りづらいプロパティ名としたり、
交差型ではなくするなどの対応もできそうです。
1-c. 処理を分岐する場合、Result型は適切に推論される
これは1-bの時点で「tagged union」が実現出来ているため問題ありません。
tagged unionは、
オブジェクトの合併型において、 各項の同一のプロパティに異なるリテラル型を指定してあげることで 該当のプロパティの値が決まれば型も決定できるよ
というものです。
とはいえ、今回用意しているisSuccess関数を用いた場合は推論ではなく
ユーザー定義のType Guardで実現されることになりますね。
const result: Result<{ a: string }, { b: number }> = someFn(); if (isSuccess(result)) { // このブロックでは result は { a: string } console.log(result.a.toUpperCase()); } else { // このブロックでは result は { b: number } console.log(result.b ** result.b); }
2. throwableなフェッチ処理をラップしてResultに変換する
目指すべきはリポジトリメソッドがPromise<Result<T, U>>を返すようになるところです。
ここで、以下の理由から共通で用いることのできるラッパーを用意しました。
- Responseの
.json()もPromiseを返すので共通処理で対応しておきたい - レスポンスのエラーや通信エラーを
CustomErrorに整形する処理を共通化したかった
しれっとCustomErrorなぞ出していますが、
エラーメッセージ組み立てをリポジトリの外側でやりたかったというのもあり
CustomErrorにエラー情報を詰め込む形で実装しています。
// 一部簡略化しています const fetchWrapper = async <ResultData, Meta = void>( fetchCaller: () => Promise<Response> ): Promise<Result<CustomError<Meta>, ResultData>> => { try { const result = await fetchCaller(); const data = await result.json(); if (!result.ok) { const error = httpErrorHandler<Meta>(data); // CustomError整形 return failure(data); } return success(data); } catch (e) { return failure( new CustomError(e, '接続エラー'); ); } }
「サーバレスポンスのエラー情報」と「通信関係のエラー」が
CustomErrorオブジェクトに変換される形になっています。
この共通関数を用いてリポジトリを以下のように修正します。
ちなみにCustomErrorはエラースタックもマージされるようになっています。
// ... find(id: string) { return fetchWrapper<UserModel>(() => fetch(`https://hoge/api/v1/users/${id}`, { /* options */ }) ) } // ...
これで記述量も少なめになり、fetch処理もリポジトリで見える状態になり、
返り値もリポジトリ側で指定出来るようになりました。
3. Thunkで成功/失敗の処理を書く
const fetchUserThunk = (id: string) => async (dispatch: Dispatch) => { // リポジトリメソッド呼び出し const result = await userRepository.find(id) if (result.success) { // result は { id: string; name: string } 型 dispatch(fetchUserSucceedActionCreator(result)); } else { // result は CustomError 型 dispatch(showErrorModal(result.message)); } } }
ちゃんと分岐時に各ブロック内で適切に型が決定されています。
やったぜ。
◇ 課題編
1. catchは引き続き要りそう
めでたしめでたしと終わらせたいところですが 後になって課題が見つかってきました。
まず、非同期処理をラップして返り値でエラーを返すようにしましたが
依然として使う側としてはPromiseを扱うことになるため、
rejectする可能性というものを考えないというものです。
// UserRepository の findメソッド find(id: string) { // リポジトリメソッド内でJSエラーが起こるような可能性もあるっちゃある // 特にレスポンスデータをラッパーの外で扱う場合に // 想定外のレスポンスデータだったりするとエラーが起きそう throw Error('例外') return fetchWrapper<UserModel>(() => fetch(`https://hoge/api/v1/users/${id}`, { /* options */ }) ) } // Thunk関数 const result = await userRepository .find(id) // ここでcatchしないと`unhandled promise rejection`が外に飛んでいく .catch(e => { throw e; }); if (result.success) { // ... } else { // ... }
リポジトリメソッド内でJSエラーが起きる件については例外機構のラップを
リポジトリクラス側で工夫すればカバーできそうですが、
Promiseというインターフェイスを扱っている時点で呼び出す側でのcatchはするべきだろうなと思います。
この課題については「Thunkでcatchし再throw、アプリケーションルートの例外ハンドラで対応」というものを考えています。
通信処理のエラーはダイアログでユーザに適切に伝える形が良いかと思いますが、
JSの想定外エラーは問題が発生したことをReactのErrorBoundaryのような仕組みで
ユーザに伝えつつSentryで拾うような対応で良いかなと考えています。
Thunk内での再throwに問題は無いか、どうcatchするかは未検討ですが
できれば次のキャンプで扱いたいと考えています。
2. 複数の非同期処理が大変
try-catchを用いれば複数の非同期処理について例外を一箇所で扱うことができます。
try { const createdSomething = await somethingRepository.createSome(some); await piyoRepository.registerPiyo(createdSomething.id); dispatch(completeSomething()); } catch (e) { dispatch(showErrorModal(e.message)); }
今回のものはこのように
成功したら引き続き非同期処理を実行、失敗した時点で例外処理ブロック
のような制御フローに対応できていません。
// 控えめに言って地獄みたいなコードが生まれてしまう... const onFailure = (e: CostomError) => dispatch(showErrorModal(e.message)); const createSomethingResult = await somethingRepository.createSomething(some); if (createdSomethingResult.success) { const piyoResult = await piyoRepository.registerPiyo(createSomethingResult.id); if (piyoResult.success) { dispatch(completeSomething()); } else { onFailure(piyoResult); } } else { onFailure(createdSomethingResult); }
現状、このようなケースでは 通常のやりかた(例外を用いる形)のほうが良さそうです。
3. fp-tsについて
これは課題というより試してみたいことになります。
今回、Resultという形でEitherを模倣しましたが、
このように関数型の概念模倣を実現するライブラリとしてfp-tsというものがあります。
gcanti/fp-ts: Functional programming in TypeScript
今回はミニマムに解決したかったため導入はしていませんが、 課題2のような問題にも上手く対応できるかもしれない(未検討)ので 少し試してみたいな〜と思っています。
個人的にも気になるライブラリなので趣味でひっそりと触ってみて 良さげだったら実戦投入などできたら良いなと思っています。
まとめ
エラー情報にも型が付き、目先の問題については解決することができました。
より適切にエラー文言を表示出来るようになったと考えられます。
しかし、実際に実装・利用してみると新たな課題が見えてきました。
挙がった課題を解決するために色々試したいではありますが、
「実は案1でシンプルに解決するのがなんだかんだ一番良い」という可能性もあります。
今回の手法に固執せず、プロジェクトのために一番良い形はなにかを引き続き模索して行きたいと思っています。
今回、キャンプ期間にこのような課題に取り組むことができ、
より品質の高いコードを追い求められる環境があるのって素晴らしいなと感じました。
これらの取り組みで自身の成長にもつながったと感じています。
この問題に進展があったときや、他の新たな課題を発見/解決した時には また記事が書けたら良いな〜と思っています。
読んで頂きありがとうございました!