TypeScript APIの型を手書きせず生成して修正漏れを減らす
APIから受け取るデータの型を、サーバー側と画面側でそれぞれ手書きしていると、片方だけ修正してしまうことがあります。
たとえば、APIの項目名をnameからdisplayNameへ変えたのに、画面側の型がnameのまま残る場合です。画面側だけを型チェックしても、手書きの型が古いことには気付けません。
今回は、API仕様からTypeScriptの型と通信コードを生成し、変更に追従しやすくする構成を紹介します。
題材の実装は、ASP.NET CoreでAPI仕様を出力し、Orvalでフロントエンドのコードを生成しています。この記事では、ツールの導入手順よりも、何を生成し、何を手書きで残すかに焦点を当てます。コードや項目名は説明用に簡略化しています。
型を2か所で管理する問題
次のような型を画面側で手書きしたとします。
変更前の型と表示処理の例type Product = {
id: string;
name: string;
};
const getLabel = (product: Product): string => product.name;APIがdisplayNameを返すように変わっても、このファイルだけなら型に矛盾はありません。TypeScriptは、別のサーバーで動いているAPIの変更を自動では知りません。
つまり、型を書いたことと、APIの実際の定義に型が合っていることは別です。
そこで、APIの定義から画面側の型を作る流れにします。
題材の実装での生成の流れサーバー側のAPI定義
↓ API仕様を出力
OpenAPIファイル
↓ Orvalで生成
TypeScriptの型・通信関数
↓ 型チェック
画面側の呼び出しコードOpenAPIは、URL、送信するデータ、返すデータなどを機械で読み取れる形式で記述する仕様です。Orvalは、その仕様からクライアントコードを生成するツールです。
題材の実装では、Webプロジェクトのビルド後にAPI仕様を出力する処理があり、フロントエンドはそのファイルを生成の入力にしています。
再生成すると古い使い方を見つけられる
API仕様の変更を反映して、次の型が生成されたとします。
変更後の型のイメージexport type Product = {
id: string;
displayName: string;
};画面が生成された型を使っていれば、product.nameの参照が型エラーになります。修正すべき場所を、画面を1つずつ開かずに探せます。
ただし、生成された型を使わず、画面側で古い型を作り直したり、anyで扱ったりすると、この効果は弱くなります。
API仕様の出力、クライアントコードの再生成、画面の型チェックまでが一連の作業です。
古いAPI仕様から再生成しても、古い型が出てくるだけです。サーバーの変更後は、どの仕様ファイルを更新したかも確認します。
生成コードと手書きコードの置き場所を分ける
題材の実装では、生成した型と通信関数を専用のディレクトリへ置き、通信の共通処理をその外へ置いています。
構成を簡略化すると、次のようになります。
ディレクトリ構成の例src/
generated/
api/
swagger.json API仕様
model/ 生成した型
client/ 生成した通信関数
shared/
api/
client.ts 手書きの通信共通処理
features/
products/ 商品画面などの処理生成先を分ける理由は、再生成で置き換えてよい範囲を明確にするためです。
題材の生成設定では、生成前に出力先を整理するcleanを有効にしています。生成先へ手書きの修正を加えると、次の生成で失われる可能性があります。出力先とcleanなどの設定は、Orval公式ドキュメントで確認できます。
たとえば、生成された通信関数にエラー処理を直接追加するのではなく、生成ツールが呼び出す共通処理や、その関数を使う側へ追加します。
API固有の情報は生成し、通信の共通処理はまとめる
自動生成すると、通信に関するすべての処理を生成ツールへ任せたくなるかもしれません。しかし、アプリごとの共通方針もあります。
| 内容 | 題材の実装での置き場所 |
|---|---|
| APIのURLやHTTPメソッド | 生成した通信関数 |
| リクエスト・レスポンスの型 | 生成した型定義 |
| 共通の通信処理 | 手書きのAPIクライアント |
| 通信エラーをアプリ用のエラーへ変換する処理 | 手書きのAPIクライアント |
| 失敗したときの画面表示 | 画面側の処理 |
題材の実装は、Orvalのmutatorという仕組みで、生成した関数から手書きの通信処理を呼び出しています。ここでいうmutatorは、生成コードと自前の通信処理をつなぐ関数です。
これにより、APIの追加時は生成コードを更新し、共通の通信方針を変えるときは共通処理を修正する、という分担ができます。
ただし、共通処理を変えると多くのAPIへ影響します。通常のJSONだけでなく、ファイル送信、ダウンロード、配列を含む検索条件など、異なる通信形式も確認する必要があります。
画面専用の値までAPIの型に入れない
APIから受け取るデータと、画面で使う表示用データは分けて考えます。
たとえば、画面専用のラベルは生成型を編集せず、変換する関数で作れます。
商品データを画面用に変換する例import type { Product } from './generated/api/model/product';
export const toProductOption = (product: Product) => ({
value: product.id,
label: `${product.displayName}(${product.id})`,
});この例はsrc直下へ置く想定で、生成されたファイル名や型名は、実際の出力に合わせます。
変換する処理が生成型を参照していれば、APIの項目名が変わったとき、この境界で型エラーに気付けます。一方、選択中かどうか、入力中かどうかなど、画面だけの状態をAPIの型へ追加する必要はありません。
API変更時の作業を1つの流れにする
題材の実装では、APIクライアントの生成をnpmスクリプトとして用意しています。変更時は、次の順序で進めると確認漏れを減らせます。
- サーバー側のAPI定義を変更する。
- API仕様ファイルを更新する。
- TypeScriptの型と通信関数を再生成する。
- 画面側を型チェックし、古い項目の参照を修正する。
- 実際のAPIと画面を接続して確認する。
生成差分では、変更した項目以外も確認します。ツールの更新や設定変更によって、無関係なAPIの出力まで変わることがあるためです。
チームの運用としては、使う生成ツールのバージョンをそろえ、同じ入力と設定で再生成できるようにすると比較しやすくなります。また、再生成後に未反映の差分がないかをCIで確認する方法もあります。CIは、コードを更新したときにビルドなどを自動実行する仕組みです。
これらは導入時に検討できる運用例で、題材のプロジェクトで、ここに挙げたすべての自動確認が行われているという意味ではありません。
型の生成と実行時の検証は別
TypeScriptの型は、実際に受け取ったJSONを検査する処理ではありません。
API仕様にはdisplayNameがあるのに、接続先の古いサーバーがnameを返す場合、型の生成とコンパイルが成功しても問題は残ります。
題材の実装にはZod用の定義を生成する設定もありますが、生成しただけで、すべての通信に検証が追加されるわけではありません。共通の通信関数は受け取った値を型として扱う箇所があり、必要な場所で検証処理を呼び出すかは別の設計です。
実行時のデータ検証については、以下の記事でも紹介しています。
どんな場合に導入するか
APIが数個で、変更も少なければ、手書きの型と通信関数の方が簡単なこともあります。コード生成には、仕様の出力、ツールの設定、再生成の運用が必要です。
導入を考える目安は、同じ項目の変更を、サーバーの型、画面の型、通信関数へ繰り返し反映しているかです。
| 困っていること | 生成で減らせる作業 | 別途必要な確認 |
|---|---|---|
| API項目の変更を画面へ反映し忘れる | 型定義の転記 | 仕様を更新して再生成したか |
| URLやHTTPメソッドを何度も手書きする | 通信関数の作成 | 認証や通信エラーの扱い |
| 生成コードに毎回同じ修正を加える | 共通処理へ移せる修正 | 再生成で変更が失われないか |
| 接続先のAPIが仕様と違う | 型生成だけでは解決しない | 実際の通信や受信データの検証 |
生成コードを導入する価値は、ファイルを自動で増やすことではなく、API定義と画面側のコードを同期させる作業を減らせることです。まずは、API仕様を更新してから画面の型チェックまで行う流れをそろえると、変更箇所を見つけやすくなります。