こんにちは。中途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でシンプルに解決するのがなんだかんだ一番良い」という可能性もあります。
今回の手法に固執せず、プロジェクトのために一番良い形はなにかを引き続き模索して行きたいと思っています。
今回、キャンプ期間にこのような課題に取り組むことができ、
より品質の高いコードを追い求められる環境があるのって素晴らしいなと感じました。
これらの取り組みで自身の成長にもつながったと感じています。
この問題に進展があったときや、他の新たな課題を発見/解決した時には また記事が書けたら良いな〜と思っています。
読んで頂きありがとうございました!