【実務・中級編】関数シグネチャにおける「オーバーロード」の順序が型推論に与える影響の検証 – TypeScript コア・型システムの基礎解析バイブル

【TypeScript】関数オーバーロードの「順序」が命取りになる理由:コンパイラが型を評価するメカニズムと安全な設計

コードレビューをしていて、次のような関数オーバーロードに遭遇したことはないだろうか。

// 良くある、しかし危険なオーバーロードの例
function process(value: string): string;
function process(value: number): number;
function process(value: string | number): string | number {
return typeof value === ‘string’ ? value.toUpperCase() : value 2;
}

このコード、一見すると何の問題もないように見える。しかし、複雑な型、特にユニオン型や条件付き型(Conditional Types)、あるいは広範なプリミティブ型が混ざり合う実務のコードベースにおいて、オーバーロードの定義順序を誤ることは、コンパイラの型推論をバグらせ、サイレントに型安全性を破壊する時限爆弾となる。

今回は、TypeScriptコンパイラがオーバーロードシグネチャをどのように評価し、どの順序でマッチさせるのか。その内部挙動の深層に迫りつつ、フロントエンドや非同期API連携の現場で絶対に破綻しない設計パターンを伝授しよう。

—

1. コンパイラはオーバーロードをどう評価するか?(Linear Searchの罠)

まず、TypeScriptのコンパイル時におけるオーバーロード解決の根本原理を理解してほしい。

TypeScriptは、オーバーロードされた関数呼び出しに遭遇すると、定義された上から順(上流から下流へ)にシグネチャを線形探索(Linear Search)する。そして、最初に「引数が割り当て可能(Assignable)」と判定されたシグネチャを無条件に採用する。

ここに最大の罠がある。つまり、「より具体的(Narrow)な型」より先に「より抽象的(Broad)な型」を定義してしまうと、具体的なシグネチャには一生制御が到達しないのだ。

悪い例:広範な型が上にある場合

type AdminUser = { role: ‘admin’; permissions: string[] };
type GuestUser = { role: ‘guest’; ip: string };
type User = AdminUser | GuestUser;

// 1. まず抽象的な User 型を受け入れるシグネチャを定義してしまう
function authorize(user: User): { accessLevel: number };
// 2. その下に、より具体的な AdminUser 用のシグネチャを定義
function authorize(user: AdminUser): { accessLevel: number; adminToken: string };

function authorize(user: User): any {
// 実装
return user.role === ‘admin’
? { accessLevel: 10, adminToken: ‘xxx’ }
: { accessLevel: 1 };
}

const admin: AdminUser = { role: ‘admin’, permissions: [‘all’] };
const result = authorize(admin);

// 【悲劇】
// result の型は { accessLevel: number; adminToken: string } ではなく、
// 上のシグネチャがヒットしたため { accessLevel: number } になり、adminToken にアクセスできなくなる!

コンパイラは `admin`(`AdminUser`型)を評価する際、上から順に見ていく。最初に見つかった `authorize(user: User)` の `User` 型に `AdminUser` は割り当て可能(Assignable)であるため、TypeScriptは即座にそのマッチで探索を打ち切る。

これが、オーバーロード順序が型推論に与える決定的な影響の正体だ。

—

2. 鉄則:オーバーロードは「最も具体的(Narrow)」から「最も抽象的(Broad)」へ並べよ

この問題を回避するための原則は極めてシンプルかつ絶対的である。

> 「Narrow to Broad(狭い型から広い型へ)」の順序で記述せよ。

先ほどの例を正しい順序にリファクタリングしてみよう。

type AdminUser = { role: ‘admin’; permissions: string[] };
type GuestUser = { role: ‘guest’; ip: string };
type User = AdminUser | GuestUser;

// 正しい順序:より具体的なシグネチャを上に置く
function authorize(user: AdminUser): { accessLevel: number; adminToken: string };
// その次に抽象的なシグネチャを置く
function authorize(user: User): { accessLevel: number };

function authorize(user: User): any {
return user.role === ‘admin’
? { accessLevel: 10, adminToken: ‘xxx’ }
: { accessLevel: 1 };
}

const admin: AdminUser = { role: ‘admin’, permissions: [‘all’] };
const result = authorize(admin);
// 期待通り! result の型は { accessLevel: number; adminToken: string } に推論される。

コンパイラは上から順に評価するため、まず `AdminUser` に厳密にマッチするかを検証し、マッチしなれば下の `User`(または `GuestUser`)へとフォールバックしていく。これが正しいオーバーロードのフローだ。

—

3. 【実践】APIクライアント設計におけるオーバーロード順序の最適解

フロントエンド開発でよくある、エンドポイントごとに異なるペイロードとレスポンスを返すAPIクライアント関数を設計してみよう。ここでもオーバーロードの順序が開発者体験(DX)と堅牢性を左右する。

以下のコードは、APIのフェッチ関数において、リクエストの型によってレスポンスの型を厳密に分岐させるプロダクションコードの例だ。

// — ドメインモデルの定義 —
interface UserProfilePayload {
endpoint: ‘/api/user/profile’;
method: ‘GET’;
}

interface UpdateSettingsPayload {
endpoint: ‘/api/user/settings’;
method: ‘POST’;
body: { theme: ‘dark’ | ‘light’ };
}

type ApiRequest = UserProfilePayload | UpdateSettingsPayload;

// レスポンスの定義
interface UserProfileResponse {
id: string;
name: string;
}

interface UpdateSettingsResponse {
success: boolean;
}

// ==========================================
// API クライアント関数のオーバーロード定義
// ==========================================

// 【重要】具体的なリクエストオブジェクトの形状を持つシグネチャを上に配置
function apiFetch(request: UpdateSettingsPayload): Promise;
function apiFetch(request: UserProfilePayload): Promise;

// フォールバックとしての抽象シグネチャ(必要に応じて)
function apiFetch(request: ApiRequest): Promise;

// — 実装シグネチャ —
async function apiFetch(request: ApiRequest): Promise {
const options: RequestInit = {
method: request.method,
body: ‘body’ in request ? JSON.stringify(request.body) : undefined,
};

const res = await fetch(request.endpoint, options);
return res.json();
}

// — 利用側のコード(完璧な型推論) —
async function run() {
// 引数に { endpoint: ‘/api/user/settings’, method: ‘POST’, body: { theme: ‘dark’ } } を渡す
const settingsRes = await apiFetch({
endpoint: ‘/api/user/settings’,
method: ‘POST’,
body: { theme: ‘dark’ },
});
// settingsRes は自動的に UpdateSettingsResponse (success: boolean) と推論される!
}

もしここで、`ApiRequest` のような抽象的なシグネチャを一番上に書いてしまうと、特定のプロパティ(`body` の有無など)に基づいた細かい型推論がスポイルされ、すべてが `Promise` や共通の大きすぎるユニオン型に潰されてしまう。

—

4. テンプレートリテラル型とプリミティブの順序崩壊

さらに高度な例として、テンプレートリテラル型や `string` などのプリミティブ型が混ざる場合を見てみよう。

// IDの種別を表す
type UserId = `user_${string}`;
type PostId = `post_${string}`;

// 誤った順序
// function fetchEntity(id: string): Promise; // string はあらゆる文字列を受け入れるため何でも吸い込む
// function fetchEntity(id: UserId): Promise;

// 正しい順序
function fetchEntity(id: UserId): Promise;
function fetchEntity(id: PostId): Promise;
function fetchEntity(id: string): Promise; // 最後に汎用型を置く

`string` 型は `user_${string}` のような厳密なパターンを包含するスーパーセット(上位の型)である。そのため、`string` を上のほうに定義してしまうと、`user_123` という具体的なIDを渡しても、コンパイラは「おっ、これは `string` に入るな!」と即座に判定し、`UserEntity` ではなく `GeneralEntity` を返してしまう。

「広大な型(Primitive, Union, Any)は常に下流へ追いやれ」。これがTypeScriptにおける不文律の鉄則だ。

—

5. まとめ:テクニカルリードからの提言

関数オーバーロードは、TypeScriptの表現力を極限まで高める強力な武器である反面、順序という「暗黙のコンテキスト」に依存しているため、メンテナンス時にバグが混入しやすい諸刃の剣でもある。

コードレビューや設計の際には、以下のチェックリストを常に頭に置いてほしい。

1. 上から順に評価されているか?(Linear Searchの意識)
2. より狭い型(具体・リテラル・テンプレート)が上に配置されているか?
3. より広い型(プリミティブ・Union・Any)が一番下にフォールバックとして配置されているか?

この規律を守るだけで、あなたの書くTypeScriptコードはコンパイル時の型パズルから解放され、圧倒的に堅牢で予測可能な美しいプロダクションコードへと昇華されるはずだ。チーム全体でこの知見を共有し、型安全の先にある快適な開発体験を掴み取ってほしい。

タイトルとURLをコピーしました