【入門編】「関数オーバーロード」と「Union型」の使い分け:保守性の高いAPI設計の境界線 – TypeScript コア・型システムの基礎解析バイブル

こんにちは!フロントエンドからNode.jsまで、コードの毛細血管までTypeScriptの型がどう巡っているかを見通すのが大好きな先輩エンジニアです。

今回は、TypeScriptの関数設計において誰もが一度は悩む「関数オーバーロード」と「Union型」の使い分けについて、お話ししていきますね。

「どっちを使っても同じようなことができる気がするけれど、将来の改修に強いのはどっちなのだろう?」
「なんだかコードがごちゃごちゃしてきた……」

そんなモヤモヤを抱えていませんか?ここをクリアすれば、あなたの型設計の引き出しは一気にプロの領域に近づきますよ。さあ、一緒に本質を紐解いていきましょう!

—

1. まずはおさらい:それぞれの基本と「見た目の違い」

TypeScriptで「複数の異なるパターンの引数を受け取りたい」と思ったとき、主に次の2つのアプローチが使われます。

1. Union型(和集合)アプローチ:引数や戻り値に `A | B` のように複数の型を許容する
2. 関数オーバーロード(Overload)アプローチ:関数の「シグネチャ(見出し)」を複数定義し、最後に1つの実装を書く

まずは、簡単な「IDまたは名前を受け取って、ユーザー情報を返す関数」を例に、それぞれの書き方を見比べてみましょう。

パターンA:Union型で受け止める実装

type User = { id: number; name: string; role: string };

// 引数が「数値または文字列」、戻り値も「単体のUserまたはUserの配列」になりうる
function fetchUser(idOrName: number | string): User | User[] {
if (typeof idOrName === “number”) {
// 数値なら単体を返す想定
return { id: idOrName, name: “Alice”, role: “admin” };
} else {
// 文字列(名前)なら複数ヒットするかもしれない想定
return [
{ id: 1, name: idOrName, role: “user” },
{ id: 2, name: `${idOrName} Sub`, role: “user” },
];
}
}

パターンB:関数オーバーロードで表現する実装

type User = { id: number; name: string; role: string };

// 1. オーバーロードシグネチャ:数値を入れたら、戻り値は「単体のUser」
function fetchUser(id: number): User;
// 2. オーバーロードシグネチャ:文字列を入れたら、戻り値は「Userの配列」
function fetchUser(name: string): User[];

// 3. 実装シグネチャ(外部からは直接見えない、内部のまとめ役)
function fetchUser(idOrName: number | string): User | User[] {
if (typeof idOrName === “number”) {
return { id: idOrName, name: “Alice”, role: “admin” };
} else {
return [
{ id: 1, name: idOrName, role: “user” },
];
}
}

一見すると、「Union型の方がコードが短くてスッキリしていそう」に見えますよね。
ですが、ここからがTypeScriptの奥深いところ。「呼び出し側の体験(DX)」と「将来の拡張性」において、この2つには決定的な違いが生まれるのです。

—

2. 呼び出し側(クライアント)から見た決定的な差

私たちがAPIを設計するとき、最も意識すべきなのは「その関数を使う人が、どれだけ迷わず、安全に使えるか」です。

Union型が抱える「戻り値の不確実さ」の罠

先ほどのUnion型版の `fetchUser` を、別の場所で呼び出す場面を想像してみましょう。

// 数値(ID)を渡してピンポイントでユーザーを取得したい!
const result = fetchUser(123);

// おっと!型定義上は `User | User[]` なので、
// エディタ(IntelliSense)は .id や .name を直接触らせてくれない!
console.log(result.name);
// ❌ コンパイルエラー: Property ‘name’ does not exist on type ‘User | User[]’.
// Property ‘name’ does not exist on type ‘User[]’.

「いやいや、私は数値を入れたんだから戻り値は単体の `User` に決まっているでしょ!」と開発者が思っても、TypeScriptの型システムは 「`number` を入れたら必ず `User` が返る」という入力と出力の紐付け(関連性) をUnion型だけでは読み取れません。結果として、呼び出し側で `Array.isArray(result)` などのガード(型絞り込み)を強制されることになります。

オーバーロードがもたらす「精緻な型マッピング」

一方、関数オーバーロードを使用した場合の呼び出しはどうなるでしょうか?

const user = fetchUser(123);
// 型推論結果: user は 「User」型として確定する!

const users = fetchUser(“Alice”);
// 型推論結果: users は 「User[]」型として確定する!

// だから、こう書いても一切エラーにならない!
console.log(user.name); // ⭕ 快適にプロパティにアクセスできる!
console.log(users.length); // ⭕ 配列のメソッドも使える!

「引数の型(入力)に応じて、戻り値の型(出力)が正確に一意に定まる」。これが、関数オーバーロードが持つ最大の武器です。呼び出し側は余計な型ガードを書く必要がなくなり、極めてクリーンで直感的なコードを書くことができます。

—

3. 保守性と拡張性の境界線:どちらを選ぶべきか?

「じゃあ、全部関数オーバーロードにすればいいのでは?」と思われるかもしれませんが、世の中そんなに甘くありません(笑)。
アーキテクトとしての経験則から、それぞれの使い分けの境界線を整理しておきましょう。

🟢 チーム開発・ライブラリ設計で「関数オーバーロード」を選ぶべきケース

  • 入力と出力の間に対strictな対応関係がある場合(例:「Aを渡したらXが返り、Bを渡したらYが返る」)
  • 外部のユーザー(他のチームメンバーやOSSの利用者)が使うパブリックなAPIを設計するとき
  • 呼び出し側の認知負荷(型ガードの手間)を極限まで減らしたいとき

🟢 内部ユーティリティや「単純な型の許容」で「Union型」を選ぶべきケース

  • 入力によって出力の構造が根本的に変わらない場合(例:引数が `string | string[]` で、処理してどちらも単一の `string` を返すなど)
  • 処理内部で型ガード(`typeof` や `Array.isArray`)を書くのが容易で、バリエーションが少ないとき
  • 将来的な拡張の余地が少なく、シンプルさを優先したいとき

—

4. 陥りがちな罠:オーバーロードの「やっちゃいけない書き方」

初学者の方がオーバーロードを書き始めると、よく次のような「コンパイルエラーの迷宮」に迷い込みます。

// ❌ やってしまいがちなミス
function processValue(value: string): string;
function processValue(value: number): number;
function processValue(value: boolean): boolean;

// 実装シグネチャ
function processValue(value: string | number | boolean) {
// 内部でうまく型が合わずエラーになったり、
// うっかり実装側のシグネチャの型を広げすぎて、
// 外から予期せぬ型を渡されてバグる温床になる
return value;
}

TypeScriptのオーバーロードは、あくまで「外側に見せる看板の数」を増やすテクニックです。
一番下の「実装シグネチャ」は外から隠蔽されますが、内部のロジックはすべてのオーバーロードパターンを安全に網羅していなければなりません。

また、シグネチャの順番も非常に重要です。TypeScriptは上から順にシグネチャをマッチさせていくため、より広範な型(例: `any` や `unknown`, または広めなUnion型)を上に書いてしまうと、下にある具体的なシグネチャが吸い取られてしまい、意図した型推論が働かなくなります。「具体的なものを上に、抽象的なものを下に」が鉄則です。

—

まとめ:型は「対話の言語」である

TypeScriptの型定義は、単なる「エラーを防ぐための防護壁」ではありません。
「この関数はこういうルールで動くんだよ」という、未来の自分やチームメイトへのラブレター(ドキュメント)です。

  • 入出力の対応関係を美しく保ち、呼び出し側のストレスをゼロにしたいなら 「関数オーバーロード」
  • シンプルの極みを目指し、内部の分岐でスマートに処理できるなら 「Union型」

この境界線を意識できるようになれば、あなたのTypeScriptコードの品質は一段も二段も跳ね上がります。

ここをクリアできれば、もう基本のモヤモヤはバッチリマスターできたも同然です!
ぜひ、今日の設計から意識して使ってみてくださいね。それでは、また次のアーキテクチャ談義でお会いしましょう!

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