🛠️

シンプルな utility で豊かなTypeScriptライフを送る

2025/04/12に公開

こんにちは。最近はもっぱらフロントエンドではなく、バックエンドの設計を行っている、フロントエンドエンジニアである 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 文を使うことで実行を中断しデバッグを容易にするアイデアを見つけました。
https://x.com/schickling/status/1910011932276379776
print debugだとcall stackを表示することまではできても、どんな状態でそこまでたどり着いたかを知るには各所にいくつもconsole.logを仕込むことになります。想定しない分岐で自動で中断することですぐにcall stackを巻き戻して変数を見ることができます。

シンプルな utility の価値

シンプルな utility には可能性があります。シンプルな API にすることで自由に組み合わせることが容易になります。例えば Promise から Result に変換する関数も実装すれば今回紹介した fetchit$ の組み合わせで柔軟なよりエラーハンドリングもできるでしょう。
ライブラリに頼らずとも、見通しがよく・制御しやすい小さなユーティリティの積み重ねで、結果的に保守性と柔軟性の高いコードベースを実現できると思います。

普段はライブラリに頼ることも多いですが、shadcn uiで再発見されたようにプロジェクト内の制御下にあることにも十分な価値が今後再評価されていくかもしれません。

Discussion