TypeScriptの型推論技術を活用したAPIクライアント実装
はじめに
TypeScriptの型システムは非常に強力で、多くの型推論技術が存在します。
この記事ではAPIクライアントの実装に関する実際のコード例を基に、TypeScriptの型推論技術とそれぞれがもたらす恩恵について解説をしていきます。
実装コード全体
今回は以下のAPIクライアント実装コードを例に、使用されている型推論技術をみていきます。
APIクライアントの実装には、HTTPクライアントライブラリとしてmandeを使用しています。
※この実装は型推論技術の解説に焦点を当てているため、可読性については改善の余地があります。
import { mande, type Options } from 'mande'
interface BlogEndpoint {
'/api/login': {
post: LoginResponse
}
'/api/articles': {
list: ArticlesResponse
detail: ArticleDetailResponse
post: ArticlePostResponse
}
}
// 存在するメソッドのみを含む型を生成
type Methods<T> = {
[K in keyof T]: K extends 'list'
? (options?: Options) => Promise<T[K]>
: K extends 'detail'
? (id: string, options?: Options) => Promise<T[K]>
: K extends 'post'
? <D = unknown>(data?: D, options?: Options) => Promise<T[K]>
: never
}
function createTypedApiClient<API>() {
type Endpoint = keyof API & string
return <Path extends Endpoint>(path: Path): Methods<API[Path]> => {
const client = mande(path)
// 全メソッドを定義するが、型システムで存在するもののみ使用可能にする
return {
list: (options?: Options) => client.get(options),
detail: (id: string, options?: Options) => mande(`${path}/${id}`).get(options),
post: <D = unknown>(data?: D, options?: Options) => client.post(data, options),
put: <D = unknown>(data?: D, options?: Options) => client.put(data, options),
delete: (options?: Options) => client.delete(options),
} as Methods<API[Path]>
}
}
const blogApiClient = createTypedApiClient<BlogEndpoint>()
このコードはAPIのインターフェース定義から型安全なAPIクライアントを自動生成する仕組みです。使用されている型推論技術を一つずつ見ていきます。
1. Mapped Types(マップ型)
type Methods<T> = {
[K in keyof T]: K extends 'list'
? (options?: Options) => Promise<T[K]>
: ...
}
Mapped Typesは、既存の型のプロパティをイテレートして新しい型を生成する技術です。[K in keyof T]という構文で、型Tのすべてのキーを走査します。
恩恵
Mapped Typesを使うことで、以下の恩恵が得られます。
- 既存の型定義から機械的に新しい型を生成できる
- 手作業で型を書く必要がなく、保守性が向上する
- エンドポイント定義を変更すると自動的にクライアントの型も更新される
- DRY原則に従った型定義が可能になる
例えば、BlogEndpointに新しいエンドポイントを追加するだけで、対応するメソッドの型が自動生成されます。
2. Conditional Types(条件付き型)
実装抜粋
K extends 'list'
? (options?: Options) => Promise<T[K]>
: K extends 'detail'
? (id: string, options?: Options) => Promise<T[K]>
: ...
Conditional Typesは、型レベルでの条件分岐を実現する技術です。T extends U ? X : Yという構文で、型Tが型Uに割り当て可能かどうかによって異なる型を返します。
実装例では、メソッド名Kに応じて異なる関数シグネチャを定義しています。
恩恵
Conditional Typesによって、以下が可能になります。
- メソッド名に応じて異なる関数シグネチャを定義できる
-
listメソッドにはIDパラメータが不要、detailメソッドにはID必須という違いを型で表現できる - コンパイル時に不正な引数の使用を検出できる
- 各メソッドの使用方法が型定義から自明になる
実際の使用例:
const articlesClient = blogApiClient('/api/articles')
// OK: listメソッドはIDなしで呼べる
const articles = await articlesClient.list()
// OK: detailメソッドはID必須
const article = await articlesClient.detail('123')
// エラー: listメソッドにIDは渡せない
// const articles = await articlesClient.list('123')
3. Generics(ジェネリクス)
実装抜粋
function createTypedApiClient<API>() {
return <Path extends Endpoint>(path: Path): Methods<API[Path]> => {
// ...
}
}
複数箇所でGenerics(ジェネリック型パラメータ)が使われています。
-
<API>: エンドポイント定義全体の型 -
<Path extends Endpoint>: 具体的なパスの型 -
<D = unknown>: リクエストボディのデータ型
恩恵
Genericsの使用により、以下のメリットがあります。
- 型の再利用性が高まる
- 呼び出し側で型パラメータを指定することで、柔軟に型を制御できる
- 型安全性を保ちながらも汎用的な実装が可能
- デフォルト型パラメータ(
D = unknown)により、型指定を省略可能
使用例:
// 型パラメータでエンドポイント定義を指定
const client = createTypedApiClient<BlogEndpoint>()
const response = await client.post({ email: '...', password: '...' })
4. Intersection Types(交差型)
実装抜粋
type Endpoint = keyof API & string
Intersection Typesは、複数の型を組み合わせて新しい型を作る技術です。&演算子で型を結合します。
ここではkeyof API(APIのキー型)とstring型を交差させています。
恩恵
この技術により、以下が実現できます。
-
keyof APIはstring literal型のunion('/api/login' | '/api/articles'のような型)を返しますが、それをstring型に絞り込めます - 文字列としての操作(テンプレートリテラルなど)が可能になる
- 型の精度を保ちながら実用的な型を得られる
// keyof BlogEndpoint の結果:
// '/api/login' | '/api/articles'
// これをstringと交差させることで、文字列としても扱える型になる
5. Index Access Types(インデックスアクセス型)
実装抜粋
Promise<T[K]>
// または
Methods<API[Path]>
Index Access Typesは、オブジェクト型のプロパティの型にアクセスする技術です。T[K]という構文で、型TのプロパティKの型を取得します。
恩恵
Index Access Typesによって、以下が可能になります。
- ネストした型定義から特定のプロパティの型を取り出せる
- 型定義の重複を避けられる
- エンドポイント定義からレスポンス型を自動的に取得できる
- 型の一貫性が保証される
実例:
// API['/api/articles'] は以下の型を返す:
// {
// list: ArticlesResponse
// detail: ArticleDetailResponse
// post: ArticlePostResponse
// }
// さらに API['/api/articles']['list'] は ArticlesResponse を返す
6. Type Assertion(型アサーション)
実装抜粋
return {
list: (options?: Options) => client.get(options),
// ...
} as Methods<API[Path]>
Type Assertion(型アサーション)は、TypeScriptコンパイラに対して「この値は指定した型である」と明示的に伝える技術です。asキーワードを使用します。
恩恵
型アサーションの使用により、以下が実現できます。
- 実装と型定義のギャップを埋める
- ランタイムでは全メソッドを持つオブジェクトを返しつつ、型レベルでは存在するメソッドのみに制限できる
- 柔軟な実装と厳密な型チェックの両立
この実装では、すべてのHTTPメソッドを持つオブジェクトを返していますが、型システム上はMethods<API[Path]>で定義されたメソッドのみが使用可能になります。
const loginClient = blogApiClient('/api/login')
// OK: postメソッドは定義されている
await loginClient.post({ email: '...', password: '...' })
// エラー: listメソッドは '/api/login' に定義されていない
// await loginClient.list()
7. Template Literal Types(テンプレートリテラル型)
実装抜粋
mande(`${path}/${id}`)
Template Literal Typesは、文字列テンプレートを型レベルで扱う技術です。JavaScriptのテンプレートリテラルと同様の構文を型定義で使用できます。
恩恵
Template Literal Typesにより、以下が可能になります。
- 動的にURLを組み立てる際も型安全性を保てる
- パスパラメータを含むエンドポイントを柔軟に扱える
- 文字列の結合を型レベルで表現できる
8. Type Inference(型推論)
実装抜粋
return <Path extends Endpoint>(path: Path): Methods<API[Path]> => {
// pathの値から型が推論される
}
Type Inference(型推論)は、TypeScriptが文脈から自動的に型を推測する機能です。明示的な型注釈がなくても、値や式から適切な型が導出されます。
恩恵
型推論により、以下のメリットがあります。
- 型注釈を書く手間が省ける
- コードがシンプルで読みやすくなる
- 値から型が自動的に決まるため、型と実装の乖離が起きにくい
- IDEの補完機能が最大限に活用できる
実際の使用時:
// pathに '/api/articles' を渡すと、Pathの型が自動的に '/api/articles' に推論される
const articlesClient = blogApiClient('/api/articles')
// そのため、返り値の型も Methods<BlogEndpoint['/api/articles']> に推論される
// つまり、list, detail, post メソッドが使える型になる
全体としての恩恵
実際に複数の型推論技術を組み合わせることで、以下の大きな恩恵が得られると感じました。
型安全性の向上
存在しないエンドポイントやメソッドを呼び出そうとするとコンパイルエラーにする事ができる。
// エラー: '/api/users' は定義されていない
const usersClient = blogApiClient('/api/users')
// エラー: '/api/login' に 'list' メソッドは存在しない
const loginClient = blogApiClient('/api/login')
await loginClient.list()
優れた開発体験
IDEの補完機能により、使用可能なエンドポイントとメソッドが自動的に提案されます。
保守性の向上
DRY原則に従った型定義により、エンドポイント定義を変更すれば、クライアントコードの型も自動的に更新されます。型情報を別途定義する必要もなく、不整合があればコンパイルエラーで検出できます。
スケーラビリティ
新しいエンドポイントを追加する際は、BlogEndpointインターフェースに定義を追加するだけで済みます。クライアント生成ロジックを変更する必要はありません。
まとめ
私はこの実装を通じて、TypeScriptの型推論技術の強力さを実感しました。それぞれの技術は単独でも有用ですが、組み合わせることで相乗効果が生まれ、極めて型安全で保守性の高いコードを実現できます。
特に以下の点が印象的でした。
- Mapped TypesとConditional Typesを組み合わせることで、柔軟かつ厳密な型定義が可能
- Genericsにより汎用性と型安全性を両立できる
- Index Access Typesで型定義の重複を排除し、一貫性を保てる
- Type Inferenceにより、コードをシンプルに保ちながら型の恩恵を最大限受けられる
TypeScriptの型システムは学習コストが高いですが、一度習得すれば開発効率と品質が大きく向上します。この記事が、型推論技術の理解と活用の助けになれば幸いです。
Discussion