開発チームのコードレビューをしていて、最も頻繁に遭遇する設計アンチパターンの一つが「とりあえずUnion型で全てを表現しようとした結果、呼び出し側に地獄のような型ガードを強いるAPI設計」だ。
「引数がこれなら戻り値はこれ、あれならあれ」という条件分岐を持つ関数を書くとき、君たちは思考停止で `string | number` のUnion型を叩きつけていないだろうか?
今回は、TypeScriptの型システムにおける「関数オーバーロード(Function Overloads)」と「Union型(Union Types)」の決定的な境界線について、コンパイラの評価メカニズムと実務の保守性の観点から徹底的に解剖する。
—
1. なぜUnion型の乱用は保守性を破壊するのか?
まず、よくある「ダメなコード」を見てみよう。IDまたはオブジェクトを受け取り、それに応じたユーザー情報を返す非同期APIのラッパー関数を想定する。
type User = { id: string; name: string; role: ‘admin’ | ‘member’ };
// ❌ 典型的な「Union型に頼り切った」アンチパターン
function fetchUserData(arg: string | { id: string; includeRole: boolean }): Promise
// 内部の実装が複雑怪奇になり、呼び出し側も地獄を見る
throw new Error(“Not implemented”);
}
このアプローチの問題点は、「入力と出力の対応関係(Causality)が型システムから失われている」点にある。
`string`(ID)を渡したのか、オブジェクトを渡したのかによって、戻り値の形状が変わるはずなのに、型定義レベルでは「入力のUnionのどれかを入れれば、出力のUnionのどれかが返る」という曖昧な結びつきしか保証されない。
結果として、この関数を使う開発者は、以下のような冗長な型ガードを書かざるを得なくなる。
const result = await fetchUserData({ id: “123”, includeRole: true });
// 🔴 呼び出し側で不要な絞り込み(Narrowing)が強制される
if (typeof result === ‘object’ && ‘role’ in result) {
console.log(result.role);
}
これはコンパイラにとっても、人間にとっても非常に非効率だ。TypeScriptの型システムは、入力値の正確な構造をすでに知っているはずなのに、それを伝えていない。
—
2. 関数オーバーロードによる「入力と出力の完全な結びつき」
ここで登場するのが関数オーバーロードだ。オーバーロードは、実装(Implementation)の前に「シグネチャ(型宣言)」を多重定義することで、入力のパターンに応じた厳密な戻り値をコンパイラに教え込むことができる。
先ほどの要件を、関数オーバーロードを使って極限まで洗練させてみよう。
// — プロダクション品質のオーバーロード設計 —
// パターンA: ID(文字列)のみを渡した場合
function fetchUserData(id: string): Promise
// パターンB: 詳細なオプション付きオブジェクトを渡した場合
function fetchUserData(options: { id: string; includeRole: boolean }): Promise
// 実際の関数実装(シグネチャの裏側で一度だけ定義する)
function fetchUserData(arg: string | { id: string; includeRole: boolean }): Promise
// 内部の実装はここで一元管理する
const id = typeof arg === ‘string’ ? arg : arg.id;
// 実装内部の型アサーションや処理
return Promise.resolve({ id, name: “Alice”, role: “admin” });
}
この設計が圧倒的に優れている理由
1. 呼び出し側のDX(開発者体験)の劇的な向上
`fetchUserData(“123”)` と呼べば戻り値は即座に `Promise
2. 将来的な拡張性(Extensibility)
将来、「キャッシュ戦略のフラグ」などを追加したくなったら、新しいオーバーロードシグネチャを1行追加するだけでいい。既存の呼び出し元を一切破壊せずに安全に拡張できる。
—
3. では、いつ「Union型」を使うべきなのか?
関数オーバーロードがこれほど強力であるならば、「全てオーバーロードで書けばいいのでは?」と思うかもしれない。しかし、それはTypeScriptの型システムを過負荷にする。
Union型を活かすべき真の領域は、「入力に対して処理の分岐が動的であり、かつ出力の構造が入力の型と1対1で厳密に固定されない(あるいはジェネリクスで汎用的に扱える)場合」だ。
例えば、イベントハンドラーや、コンポーネントのプロパティ設計などがそれに該当する。
// ⭕️ Union型が正しく機能する例:コンポーネントの排他的プロパティ(Discriminated Unions)
type ButtonProps =
| { variant: ‘primary’; onClick: () => void }
| { variant: ‘link’; href: string; external?: boolean };
function renderButton(props: ButtonProps) {
if (props.variant === ‘link’) {
// TypeScriptはここでpropsが後半の型であると100%確定させる(判別可能Union)
return `${props.href}`;
}
// こちらは primary
return ``;
}
ここではオーバーロードではなく、Discriminated Union(判別可能Union型)を使うべきだ。なぜなら、プロパティオブジェクトを一旦変数に格納して後から渡すようなケースにおいて、Union型の方が変数の型推論として扱いやすいからだ。
—
4. チーフアーキテクトが教える「設計の境界線」判断アルゴリズム
コードレビューで後輩にどちらを採用すべきか聞かれたら、私はいつもこの基準で判断するように伝えている。
| 評価軸 | 関数オーバーロード (Overloads) | Union型 (Union Types) |
| :— | :— | :— |
| 主なユースペック | 引数の型によって戻り値の型が劇的に変わる場合 | 受け取るデータ自体が複数のパターンのいずれかである場合 |
| 拡張性 | パターン追加時はシグネチャを足すだけ(安全) | Unionの数が増えると型ガードの分岐が爆発する |
| 可読性 | 関数の入り口でパターンが一望できる | 複雑化するとIDEのホバー情報が読みにくくなる |
| コンパイル負荷 | 適切に使えば最小限 | 過度なネストはコンパイル速度を低下させる |
黄金律
- 「関数を呼び出すときの入力と出力の因果関係を固定したい」 なら 関数オーバーロード を選べ。
- 「データ構造そのものが複数の状態を持ちうる(ドメインモデルの表現)」 なら Union型(特にDiscriminated Union) を選べ。
—
結びにかえて
TypeScriptの型定義は、単なる「エラーを防ぐためのボルト」ではない。それは、次にそのコードを触る開発者(あるいは半年後の自分)への最高級のドキュメントであり、APIの設計思想そのものだ。
「とりあえずUnion型」という思考停止を捨て、関数のシグネチャに意図を宿せ。型定義の美しさは、そのままプロダクトの品質に直結する。今日のレビューから、その設計を見直してみてほしい。