🐡

TypeScript でオーバーロードシグネチャよりジェネリックを使った条件型を優先して使用すべき理由

に公開

TypeScript で関数の型定義時、「引数の型に応じて戻り値の型を変えたい」というケースがありますよね。そのようなときに選択肢としてあるオーバーロードシグネチャですが、多くのケースで条件型を使った方がより良い策となります。

本記事では、オーバーロードシグネチャと条件型の違いを詳しく比較し、なぜ条件型を優先すべきなのかを実例とともに解説します。この記事を読めば、TypeScript でより型安全で拡張性の高いコードが書けるようになります。

オーバーロードシグネチャとは関数に複数の呼び出し方法を定義する仕組み

オーバーロードシグネチャは、同じ関数に対して複数の異なるシグネチャを定義する機能です。シグネチャとは、関数の「設計図」や「契約書」のようなもので、関数名、引数の型、戻り値の型などの情報を含みます。

基本的な使い方

まずは、入力値を 2 倍にするdouble関数を例に見てみましょう。

// ↓ ここから2つがオーバーロードシグネチャ(シグネチャ定義のみ)
function double(x: string): string; // 1つ目のオーバーロードシグネチャ
function double(x: number): number; // 2つ目のオーバーロードシグネチャ

// ↓ ここが実装シグネチャ(実際の処理を書く)
function double(x: string | number): string | number {
  return typeof x === "string" ? x.repeat(2) : x * 2;
}

オーバーロードシグネチャの重要なポイント

オーバーロードシグネチャを使う際に押さえておくべき 3 つのポイントがあります。

実装シグネチャは外部から見えない
上記の例で言えば、実装シグネチャ部分の function double(x: string | number): string | number は内部実装のためだけに存在します。外部から関数を呼び出すときは、最初の 2 つのオーバーロードシグネチャのみが候補として表示されます。

// 関数を呼び出すとき、IDEには以下の2つの選択肢が表示される
double("hello"); // 1つ目のオーバーロードシグネチャが適用 → string型
double(123); // 2つ目のオーバーロードシグネチャが適用 → number型

// 実装シグネチャの型(string | number)は見えない

実装シグネチャはすべてのオーバーロードを受け入れる必要がある
実装シグネチャの引数型(string | number)は、すべてのオーバーロードシグネチャの引数型(string, number)を含んでいる必要があります。これにより、どのオーバーロードで呼び出されても適切に処理できます。

型推論は基本的なレベルにとどまる
オーバーロードシグネチャでは、入力された具体的な値の詳細な型情報(リテラル型など)が実装内部に伝わりにくく、戻り値も基本的な型レベルでの推論となります。

条件型とは型レベルでの条件分岐を可能にする機能

条件型は、型レベルでの条件分岐を可能にします。基本的な構文は T extends U ? X : Y で、「型 T が型 U に代入可能であれば X、そうでなければ Y」という意味になります。

基本的な使い方

// 基本的な条件型の例
type IsString<T> = T extends string ? true : false;

type Test1 = IsString<string>; // true
type Test2 = IsString<number>; // false
type Test3 = IsString<boolean>; // false

実用的な例

先ほどのdouble関数を条件型で書いてみましょう。

// 条件型を使って戻り値の型を決定
type DoubleReturn<T> = T extends string
  ? string
  : T extends number
  ? number
  : never;

// 条件型を使った関数
function doubleWithConditional<T extends string | number>(
  x: T
): DoubleReturn<T> {
  if (typeof x === "string") {
    return x.repeat(2) as DoubleReturn<T>;
  } else {
    return ((x as number) * 2) as DoubleReturn<T>;
  }
}

条件型の 主な 3 つのメリット

型の完全な保持
ジェネリクスと組み合わせることで、入力された型の詳細な情報をそのまま出力型に反映できます。

柔軟な拡張性
新しい型への対応が容易で、条件を追加するだけで機能を拡張できます。

コンパイル時の最適化
型レベルでの計算のため、実行時のオーバーヘッドがありません。

オーバーロードシグネチャの困りどころと条件型の優位性

実際の開発では、オーバーロードシグネチャを使うと以下のような問題に直面することがあります。条件型を使うことで、これらの問題を解決できます。

型システムでの処理方法の違い

オーバーロードシグネチャと条件型では、TypeScript の型システムでの処理方法が根本的に異なります。

オーバーロードシグネチャの場合

  • 複数の独立したシグネチャとして扱われます
  • TypeScript は呼び出し時に上から順番にマッチするものを探します
  • 各シグネチャは個別に処理されるため、全体としての一貫性が保証されません

条件型の場合

  • ユニオン型として一つの統一された型システムで処理されます
  • 分散条件型(Distributive Conditional Types)として動作します
  • 一つの式として分析されるため、より正確で一貫性のある型推論が可能になります
type ExtractElement<T> = T extends (infer U)[] ? U : never;

// オーバーロードでは表現が困難
// function getElement(arr: string[]): string;
// function getElement(arr: number[]): number;
// function getElement(arr: boolean[]): boolean;
// // 新しい型が追加されるたびにシグネチャを追加する必要がある

// 条件型では一つの定義で全てをカバー
function getElement<T extends readonly unknown[]>(arr: T): ExtractElement<T> {
  return arr[0] as ExtractElement<T>;
}

const stringEl = getElement(["a", "b"]); // string型
const numberEl = getElement([1, 2, 3]); // number型
// 任意の配列型に対して自動的に適切な要素型が推論される

ジェネリクスとの組み合わせが困難

オーバーロードシグネチャは個別のシグネチャとして処理されるため、ジェネリクスとの相性が悪く、型の統一的な処理ができません。一方、条件型はユニオン型として一つの式で処理されるため、ジェネリクスと完璧に組み合わせることができます。

// 条件型版はジェネリクスと組み合わせることで、より柔軟な型定義が可能
function triple<T extends string | number>(
  x: T
): T extends string ? string : number {
  return (
    typeof x === "string" ? x.repeat(3) : (x as number) * 3
  ) as T extends string ? string : number;
}

// 関数を組み合わせても型情報が正確に保持される
const result = triple(doubleWithConditional("test")); // string型が正確に推論される

拡張時のコスト増加

新しい型をサポートする場合、オーバーロードシグネチャでは新しいシグネチャを追加する必要がありますが、条件型では条件を 1 つ追加するだけで済みます。

// オーバーロードシグネチャの場合:新しいシグネチャが必要
function doubleOverloadExtended(x: string): string;
function doubleOverloadExtended(x: number): number;
function doubleOverloadExtended(x: boolean): boolean; // 新しい型のために追加
function doubleOverloadExtended(
  x: string | number | boolean
): string | number | boolean {
  if (typeof x === "string") {
    return x.repeat(2);
  } else if (typeof x === "number") {
    return x * 2;
  } else {
    return x && x; // boolean の場合の論理的な2倍
  }
}

// 条件型の場合:条件を追加するだけ
type DoubleReturnExtended<T> = T extends string
  ? string
  : T extends number
  ? number
  : T extends boolean // 新しい型のために1行追加
  ? boolean
  : never;

まとめ

TypeScript で引数の型に応じて戻り値の型を変えたい場合、多くのケースで条件型がオーバーロードシグネチャよりも優れた解決策となります。

条件型の主な利点は以下の通りです

  • 柔軟な型変換: 入力型に基づいて適切な出力型を動的に決定できます
  • ジェネリクスとの相性: より精密で柔軟な型推論が可能になります
  • 高い拡張性: 新しい型への対応が容易で、コードの保守性が向上します
  • 関数組み合わせ時の型安全性: 複数の関数を組み合わせても型情報が正確に伝播されます

条件型を使用することで、TypeScript の型システムをより深く活用できるようになり、開発効率と品質の向上につながるのでぜひ使ってみてください!

Discussion