【TypeScript】ジェネリック型の基礎:柔軟な型定義の方法

TypeScript

TypeScriptでの型定義は、コードの安全性と可読性を高める重要な要素です。
しかし、特定の型に固定されてしまうと、柔軟性が損なわれる場面もあります。

そこで登場するのが「ジェネリック型」(Generic Types)です。
本記事では、ジェネリック型の基本概念から使用方法までをわかりやすく解説します。

ジェネリック型とは

ジェネリック型とは、型を引数として扱う仕組みです。「型のプレースホルダー」のようなものと言っても良いでしょう。
これにより、次のようなメリットがあります。

  • クラスインターフェース、関数(メソッド)において様々な型を柔軟に適用できます。
  • 同じロジックを異なる型に対して再利用でき、コードの重複を減らすことができます。

Java言語の経験者の場合、「ジェネリクス」としてある程度は馴染みのある機能かと思います。
そうでない場合は、慣れるまでは難しく感じるかもしれませんので、実際に手を動かしてコードを書き、実行させながら慣れていってください。

ジェネリック型の基本的な構文

ジェネリック型は、<T> のような型パラメータを使用して定義します。この型パラメータは、関数(メソッド)やクラス、インターフェースなどで使用され、具体的な型は呼び出し時に指定されます。

また、具体的な型を指定せず呼び出した場合には、型推論されます。

TypeScript
function identity<T>(arg: T): T {
  return arg;
}

let output = identity<string>("myString"); // 文字列型を指定
let output2 = identity(100); // 型推論により数値型(number)になる

上記の例では、identity関数は引数として受け取った値をそのまま返します。<T>の記述によって、この関数はstring型やnumber型など、あらゆる型に対応できます。

なぜ T なのか?

開発者がすぐに「これは型引数だ」と認識できるように、T (Typeの略)が慣例的に使われています。ただし、特定の用途に応じた名前(例: U, Keyなど) でも問題ありません。

ジェネリック関数

ジェネリック関数は、型パラメータを持つ関数です。これにより、引数や戻り値の型を柔軟に指定できます。

TypeScript
function printData<T>(data: T): void {
  console.log(`Data: ${data}`);
}

printData<number>(100);    // number型指定
printData("ジェネリック"); // string型(型推論により)
printData(true);           // boolean型(型推論により)

上記の例では、<T>が型パラメータで、関数呼び出し時に実際の型が決定します。この関数は任意の型のデータを受け取り、型情報を保持したまま処理できます。

関数に指定した型パラメータは、通常、引数あるいは戻り値のいずれかに使用する必要があります。
引数、戻り値のいずれにも型パラメータが使用されていない場合でもコンパイルエラーにはなりませんが、ジェネリック型を使う意味が薄れてしまうため、避けるべきです。

ジェネリッククラス

ジェネリッククラスのサンプルプログラムとコード解説を示します。

TypeScript
class Box<T> {
  private content: T;

  constructor(item: T) {
    this.content = item;
  }

  getItem(): T {
    return this.content;
  }
}

const numberBox = new Box<number>(100);
const stringBox = new Box<string>("TypeScript");

上記の例では、クラス全体で同じ型パラメータTを使用しています。
インスタンス生成時に型を指定することで、クラス内部の処理を型安全に保ちつつ再利用可能になります。

クラスに指定した型パラメータは、通常、クラスのプロパティまたはメソッドの引数あるいは戻り値のいずれかに1度以上は使用する必要があります。
クラスのプロパティとメソッドのいずれにも型パラメータが使用されていない場合でもコンパイルエラーにはなりませんが、ジェネリック型を使う意味が薄れてしまうため、避けるべきです。

ジェネリックインターフェイス

ジェネリックインターフェイスのサンプルプログラムとコード解説を示します。

TypeScript
interface KeyValuePair<K, V> {
  key: K;
  value: V;
}

const numberPair: KeyValuePair<number, string> = {
  key: 1,
  value: "One"
};

const stringPair: KeyValuePair<string, boolean> = {
  key: "active",
  value: true
};

インターフェイス定義時に複数の型パラメータ(今回の例ではKV)を使用することで、柔軟な型定義が可能になります。

ジェネリック型の制約

制約の種類

  1. extendsによる基本制約
  2. 複合型制約(union types)
  3. keyof演算子との組み合わせ
  4. カスタム型による制約

extendsによる基本制約のサンプル

次の例では、extendsで型の制約を追加し、特定のプロパティ(今回の場合はlength)を持つ型のみを受け付けるようにしています。

TypeScript
interface Lengthwise {
  length: number;
}

function logLength<T extends Lengthwise>(arg: T): void {
  console.log(arg.length);
}

logLength("text");  // 4(文字列のlengthプロパティ)
logLength([1,2,3]); // 3(配列のlength)
logLength(100);     // エラー(number型にlengthプロパティなし)

複合型制約(union types)のサンプル

次の例では、複合型(union)を使用することにより、型パラメータ Tstringまたはnumberのいずれかである必要があることを指定しています。
それ以外の型の引数を与えると、コンパイルエラーが発生します。

TypeScript
type StringOrNumber = string | number;

function processValue<T extends StringOrNumber>(value: T): string {
  if (typeof value === "number") {
    return `Number: ${value.toFixed(2)}`;
  } else {
    return `String: ${value.toUpperCase()}`;
  }
}

console.log(processValue("hello")); // String: HELLO
console.log(processValue(123.456)); // Number: 123.46
console.log(processValue(true); // コンパイルエラー

keyof演算子との組み合わせのサンプル

次の例では、型パラメータ K が型パラメータ T のプロパティキー(今回の場合は nameage)のいずれかである必要があることを指定しています。
それ以外の引数を与えると、コンパイルエラーが発生します。

TypeScript
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = { name: "John", age: 30 };
console.log(getProperty(person, "name"));   // John
console.log(getProperty(person, "age"));    // 30
console.log(getProperty(person, "gender")); // コンパイルエラー 

カスタム型による制約のサンプル

次の例では、型パラメータ TPrintable インターフェースを実装している必要があることを明示的に指定しています。

TypeScript
interface Printable {
  print(): void;
  isColor: boolean;
}

class Document implements Printable {
  print() {
    console.log("資料を印刷中...");
  }
}

class Report implements Printable {
  print() {
    console.log("レポートを印刷中...");
  }
}

function printItem<T extends Printable>(item: T) {
  item.print();
}

printItem(new Document()); // 資料を印刷中...
printItem(new Report());   // レポートを印刷中...

<T extends Printable> を見て、なぜ <T implements Printable> ではないのかと疑問に思った方もいるかもしれません。

ジェネリック型の制約では、implements は使用されません。ジェネリック型では、extends を使用して、型パラメータがインターフェースを実装していることを制約します。

ジェネリック型のアンチパターン

不必要な複雑化

ジェネリック型は、コードの再利用性と型安全性を高める強力なツールですが、単純な型で十分に表現できる場合にまで過度に使用すると、コードの可読性と保守性を著しく低下させる可能性があります。
特に、特定の型に依存しない汎用的な関数やクラスを作成する必要がない場合にジェネリック型を使用すると、コードが不必要に複雑になります。

具体例として次のようなケースが考えられます。

  • 単純な文字列操作を行う関数にジェネリック型を使用する。
  • 特定の型の配列のみを処理するクラスにジェネリック型を使用する。

不必要な複雑化を防ぐには、次の観点を事前にチェックしておくことをおすすめします。

  • ジェネリック型を使用する前に、単純な型で解決できないか検討する。
  • ジェネリック型を使用する必要がある場合でも、型パラメータの数を最小限に抑える。
  • コードの可読性を高めるために、適切な型エイリアスを使用する。

型情報の喪失

ジェネリック型の型制約が緩すぎる場合、型パラメータがany型に近い状態になり、ジェネリック型を使用する意味を失います。

具体例として次のようなケースが考えられます。

  • extends anyを使用して、任意の型の引数を受け入れる関数を作成する。
  • extends {}を使用して、任意のプロパティを持つオブジェクトを受け入れるクラスを作成する。

型情報の喪失を防ぐには、次の観点を事前にチェックしておくことをおすすめします。

  • 型制約をできるだけ具体的にする。
  • 特定のインターフェースや型エイリアスを使用して、型パラメータの範囲を制限する。
  • unknown型を使用して、型安全性を維持しながら柔軟性を高める。

ネスト過多

複雑なジェネリック型の入れ子構造は、コードの可読性と保守性を著しく低下させる可能性があります。
特に、複数の型パラメータが相互に依存するような複雑な構造は、コードの理解とデバッグを困難にします。

具体例として次のようなケースが考えられます。

  • 複数のジェネリック型を入れ子にして、複雑なデータ構造を表現する。
  • ジェネリック型を返すジェネリック関数を複数組み合わせる。

ネスト過多になるのを防ぐには、次の観点を事前にチェックしておくことをおすすめします。

  • 複雑なジェネリック型の入れ子構造を避けるために、コードをより小さな関数やクラスに分割する。
  • 型エイリアスを使用して、複雑な型を簡潔に表現する。
  • コードの可読性を高めるために、適切なコメントを追加する。

まとめ

ジェネリック型はTypeScriptの強力な機能ですが、適切に使用する必要があります。
基本原則を押さえ、型安全性とコードの再利用性のバランスを意識することが重要です。


本記事についての質問、誤りの指摘、ご意見ご感想などありましたら、ぜひコメント頂ければ幸いです。

最後までお読みいただき、ありがとうございました。

コメント

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