開発現場のコードレビューで、もっとも議論が白熱し、かつ設計者の技量が露呈するのが「関数シグネチャの設計」だ。
特に、入力値のパターンによって戻り値の型が変化するようなユーティリティ関数やカスタムフックを実装する際、「関数オーバーロード(Overloads)」を採用すべきか、それとも引数と戻り値に「Union型」を適用すべきか——この二者択一で迷った経験はないだろうか。
ネット上の初学向け記事では「オーバーロードは複雑だからUnion型にしよう」「いや、オーバーロードのほうが綺麗だ」といった表面的な好みが語られがちだが、TypeScriptの型システム、そしてコンパイラの型推論メカニズムを深く理解している者からすれば、これは「静的安全性の担保」と「開発体験(DX)・パフォーマンス」のトレードオフを制御する極めて重要なアーキテクチャ上の判断である。
今回は、フロントエンドのコンポーネント設計や非同期API連携の現場で頻出するケーススタディを通じ、どちらのアプローチを選ぶべきか、その絶対的な設計基準をコードレビューの視点から伝授しよう。
—
1. 結論:判断のための単一基準
まず結論から述べよう。設計基準は以下の1行に集約される。
> 「入力の型と出力の型に1対1の厳密な相関関係(リレーション)がある場合は『オーバーロード』、入力が単なるバリエーションであり、出力がその許容範囲の網羅的なUnionになる場合は『Union型』を選択せよ」
この原則を無視して安易にUnion型を乱用すると、呼び出し側で不要な型ガードや型アサーション(`as`)の地獄が生まれ、TypeScriptの恩恵が半減する。逆に、オーバーロードの無駄な乱用は、コンパイル速度の低下やドキュメントの肥大化を招く。
実際のコードを通じて、この違いを骨の髄まで理解しよう。
—
2. ケーススタディ:APIクライアントのフェッチ関数
実務でよくある「エンドポイントのパスに応じて、返ってくるレスポンスの型が完全に決まる」という非同期APIラッパーを例に取る。
❌ 悪手:Union型によるナイーブな実装
まずは、引数と戻り値の双方をUnion型で定義した、よくある「動かないわけではないが保守性の低い」コードを見てほしい。
// 許容するエンドポイントの定義
type Endpoint = ‘/api/user’ | ‘/api/posts’;
// レスポンスの定義
interface UserResponse {
id: string;
name: string;
}
interface PostResponse {
postId: string;
title: string;
}
// ❌ 脆弱なUnion型シグネチャ
declare function fetchApi(endpoint: Endpoint): Promise
// — 呼び出し側の世界 —
async function run() {
// 開発者「/api/user を渡したんだから、res は UserResponse のはずだよね?」
const res = await fetchApi(‘/api/user’);
// 💥 エラー!
// TypeScriptコンパイラ「res は UserResponseかもしれないし PostResponseかもしれない。
// だから id や postId にアクセスするのは危険だ」
// console.log(res.id); // TS2339: Property ‘id’ does not exist on type ‘UserResponse | PostResponse’.
}
なぜこのコードは非効率で危険なのか?
TypeScriptのコンパイラは、このシグネチャを見たとき「どの引数が渡されたときに、どの戻り値が確定するのか」という因果関係を認識できない。結果として、戻り値は常に `UserResponse | PostResponse` という「巨大な合算(Union)」として評価される。
これを呼び出し側で解決しようとすると、`if (‘id’ in res)` のような冗長な型ガードを書かされるか、最悪の場合 `(res as UserResponse).id` といった型アサーション(暴力)がコードベースに蔓延することになる。これはTypeScriptの型安全性を自ら捨てる行為に他ならない。
—
⭕ 模範解答:関数オーバーロードによる厳密な相関の定義
この問題に対するエレガントな解決策が関数オーバーロードだ。実装本体(Implementation Signature)の手前に、型安全なシグネチャ(Overload Signatures)を多重定義する。
type Endpoint = ‘/api/user’ | ‘/api/posts’;
interface UserResponse {
id: string;
name: string;
}
interface PostResponse {
postId: string;
title: string;
}
// — ⭕ 堅牢な関数オーバーロードの定義 —
function fetchApi(endpoint: ‘/api/user’): Promise
function fetchApi(endpoint: ‘/api/posts’): Promise
// 実装シグネチャ(外部からは直接見えない、内部を辻褄合わせするための定義)
async function fetchApi(endpoint: Endpoint): Promise
// 実際のフェッチ処理(モック)
const response = await globalThis.fetch(endpoint);
return response.json();
}
// — 呼び出し側の世界(DXの劇的な向上) —
async function runOptimized() {
// 1. ‘/api/user’ を渡した瞬間、コンパイラは戻り値が Promise
const userRes = await fetchApi(‘/api/user’);
console.log(userRes.id); // ✨ 型安全にアクセス可能!
// 2. ‘/api/posts’ を渡せば、もちろん PostResponse に確定する
const postRes = await fetchApi(‘/api/posts’);
console.log(postRes.title); // ✨ 完璧な補完と型推論!
}
なぜこれが優れているのか?
コンパイラはオーバーロードを上から順に評価し、最初にマッチしたシグネチャの戻り値型をピンポイントで採用する。呼び出し側は無駄な型ガードを書く必要が一切なくなり、IDEのインテリセンスには「その入力に最適化された正確な型」だけが表示されるようになる。
—
3. では、いつ「Union型」を使うべきなのか?
すべての関数をオーバーロードにすればいいかというと、答えは「NO」だ。
入力値のバリエーションに対して、戻り値の型構造が同一である場合や、引数の組み合わせが爆発的に多く、かつロジックが共通化されている場合は、オーバーロードを書くのはコードの肥大化(ボイラープレートの増加)を招くだけである。
以下の実務的な「イベントリスナー登録関数」の例を見てほしい。
type EventTypes = ‘click’ | ‘focus’ | ‘blur’;
// ⭕ 引数がUnion型、戻り値がvoid等で共通化できる場合はUnionが正解
function addGlobalEventListener(
event: EventTypes,
callback: (e: Event) => void
): void {
window.addEventListener(event, callback);
}
// 呼び出し側もシンプルで、オーバーロードの必要性がない
addGlobalEventListener(‘click’, (e) => {
// 共通の Event 型として安全に処理できる
});
ここであえてオーバーロードを書くのは、ただコードを冗長にするだけであり、コンパイラの評価コスト(型推論のパフォーマンス)を無駄に押し上げる原因になる。
—
4. チーフアーキテクトが教える、プロダクションコードの極意
実務の現場でコンポーネント設計やユーティリティ作成を行う際、以下のチェックリストを頭に叩き込んでおいてほしい。
1. 入力と出力の「マッピング」を意識せよ
`A ➔ X`, `B ➔ Y` という明確な対応関係があるなら、迷わずオーバーロードを選べ。
2. 実装シグネチャの「緩さ」に気をつけろ
オーバーロードを書く際、最後の「実装シグネチャ」の型は、外部の利用者から隠蔽されるとはいえ、内部ロジックを支えるために広く(Union等で)定義する必要がある。ここを雑に書くと内部で型エラーに悩まされるため、`any` や `unknown` で逃げず、許容するUnion型をしっかりと記述すること。
3. Template Literal Types との融合
現代のTypeScript(v4.1以降)では、テンプレートリテラル型とオーバーロードを組み合わせることで、さらに強力な型制約を作ることができる。
// 応用例:プレフィックスに応じた動的なイベントハンドラの型安全化
function on(event: `user:${‘login’ | ‘logout’}`): void;
function on(event: `post:${‘create’ | ‘delete’}`): void;
function on(event: string): void {
// 実装
}
結び
TypeScriptの型システムは、単なる「エラーチェッカー」ではない。それは「開発者とコンパイラの間で交わされる、最も厳密で美しい契約書」である。
「動けばいいや」とUnion型を適当に放り込んだコードは、半年後のチームメンバー(あるいは未来の自分)の足を必ず引っ張る。入力と出力の相関関係を見極め、オーバーロードとUnion型を適切に使い分けること。その細部へのこだわりこそが、プロダクションの品質を極限まで高める唯一の道なのだ。