シンプルな utility で豊かなTypeScriptライフを送る
こんにちは。最近はもっぱらフロントエンドではなく、バックエンドの設計を行っている、フロントエンドエンジニアである oosawy です。
今回は設計やロジックの実装をするにあたって、冗長で複雑なTypeScriptコードをシンプルな utility 関数で見通しをよくすることができたので共有したいと思います。
Result 型からエラーを伝播させる utility
まずは Result
型のような表現からエラーを伝播させる utility 関数です。前述したように TypeScript/JavaScript にはエラーハンドリングに課題があります。Golangのように変数の再宣言ができて、if文を使ってreturnすることで単純に書き下すこともできなければ、Rustのように高機能なResult型と構文のサポートによるエラー伝播もできません。
コミュニティでもしばしば Result 型を実装する試みがありますが、言語機能のために見通しよく実現するのが難しいのが現状です。 async/await
がない Promise
チェーンを想像するとわかりやすいかもしれません。
これは { data: T; error: U }
のような Result
型を扱う utility です。特に今回実装したのは Result
型を返す supabase を扱うモチベーションがありました。そのためエラーを伝播させる expect
のみを実装していますが、必要に応じて同じぐらいシンプルに map
など Result
型らしく扱うメソッドを実装できるでしょう。
const queryPosts = () => supabase.from('posts').select()
const data = await $(queryPosts()).expect('Failed to query posts')
const $ = <
Result extends { data: unknown; error: unknown },
Data = Extract<Result, { error: null }>['data']
>(promise: PromiseLike<Result>) => {
const result = await promise
return {
expect: async (message: string): Promise<Data> => {
if (result.error) {
throw new Error(message, { cause: result.error })
} else {
return result.data as Data
}
},
}
}
シンプルな fetch の wrapper
これは Next.js Route Handlers で連続するAPI呼び出すをしたときに書いた fetch の wrapper です。
fetch
API は HTTP リクエストをシンプルに抽象化したもので個人的には好きなのですが、シンプルなだけに単純にデータを取得したいケースではやや冗長になってしまいます。
特に課題に感じるところは fetch API の特徴でもあるリクエスト自体とレスポンスの読み込みが別々になるところでしょう。もちろん const data = await (await fetch(endpoint)).json()
と書くこともできるのですが、エラーハンドリングまで考えると分ける必要が出てきます。そのために都度複数の変数を定義したり try/catch
内から結果を出すために let
を使うことになったり、コードの見通しが悪くなってきます。
一度限りの実装なら素朴に書いてもよいかもしれませんが、今回のケースでは3回連続するデータ取得があり、毎回変数名を変える必要もあります。これは JavaScript で let
, const
で定義した変数の再宣言ができないことによります。
これを回避するために axios をはじめとするライブラリを使用することが一般的ですが、単純にデータを取得したいケースではオーバースペックに感じられたり、内部実装が不透明に感じてしまいます。特にデファクトな axios の場合は内部で fetch API ではなく XMLHttpRequest を使用していることもあり動作が微妙に異なることを十分理解することが必要です。またそのほかに大きな理由として賛否が分かれていますが Next.js が fetch API に独自の意味やオプションを持たせていることでも、内部に隠蔽されたライブラリよりもプロジェクトのコードベース内の制御下にあるモチベーションが大きかったです。
そこで fetch API のように使えて、エラーハンドリングと axios などのように .json()
チェーンができる fetchit
warpper を書きました。
const { token } = await fetchit<{ token: string }>(API_HOST + '/v1/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).json()
const fetchit = <T>(url: string, options: RequestInit) => {
const promise = (async () => {
try {
const response = await fetch(url, options)
if (!response.ok) {
response.text() // body must to be consumed to prevent memory leak
throw new Error(`response not ok: ${response.status} ${response.statusText}`)
}
return response
} catch (error) {
throw new Error('Fetch failed: ' + url, { cause: error })
}
})()
return Object.assign(promise, {
json: (): Promise<T> => promise.then((res) => res.json()),
})
}
この実装で特徴的なのは Object.assign(promise, { json: () => {} })
でしょう。まず promise
を返すことで通常の fetch API と同じ動作をさせます。そこに json メソッドを付け加えることでメソッドチェーンが可能になります。
定外の例外が発生した時のデバッグを容易にする
これは自分が思いついたものではなく X で見かけたものになりますが、通常発生することを想定しない分岐で debugger
文を使うことで実行を中断しデバッグを容易にするアイデアを見つけました。
print debugだとcall stackを表示することまではできても、どんな状態でそこまでたどり着いたかを知るには各所にいくつもconsole.logを仕込むことになります。想定しない分岐で自動で中断することですぐにcall stackを巻き戻して変数を見ることができます。
シンプルな utility の価値
シンプルな utility には可能性があります。シンプルな API にすることで自由に組み合わせることが容易になります。例えば Promise
から Result
に変換する関数も実装すれば今回紹介した fetchit
を $
の組み合わせで柔軟なよりエラーハンドリングもできるでしょう。
ライブラリに頼らずとも、見通しがよく・制御しやすい小さなユーティリティの積み重ねで、結果的に保守性と柔軟性の高いコードベースを実現できると思います。
普段はライブラリに頼ることも多いですが、shadcn uiで再発見されたようにプロジェクト内の制御下にあることにも十分な価値が今後再評価されていくかもしれません。
Discussion