TypeScript基礎講座 #6 ジェネリクス深掘り

読了 9分

前回は関数の型を扱いながら、ジェネリクスとの初対面を果たしました。今回はその上にもう一歩踏み込んで、ジェネリクスの本当の表現力を引き出す道具たち — 制約、複数の型パラメータ、ジェネリックインターフェース/クラス、keyof、インデックスアクセス型を整理します。

今回の内容は最初は難しく感じるかもしれません。すべてのパターンを一度に覚える必要はなく、「こういう道具がある」という感覚をつかんでいただければ十分です。

制約の復習 — extends #

#5でちらっと見た制約。ジェネリック型に条件を課す道具です。

制約 — lengthがある型のみ
function logLength<T extends { length: number }>(value: T): void {
  console.log(value.length);
}

logLength('hello');       // ✓
logLength([1, 2, 3]);     // ✓
logLength(42);            // 🚫

T extends 形は「Tはその形と互換性のある何らかの型」という意味です。関数本体ではその形に依存するコードを安全に書けるようになります。

複数の型パラメータ #

複数の型変数が一緒に使われるとき、それらの間の関係を表現できます。

keyを安全に取り出す
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 'u-1', name: 'チョルス', age: 30 };

const id = getProperty(user, 'id');       // string
const age = getProperty(user, 'age');     // number
const bad = getProperty(user, 'foo');     // 🚫 'foo'はuserに無い

ここで重要な要素が二つ登場します。

keyof T #

keyof TTのすべてのキー名のunionです。

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

type UserKeys = keyof User;   // 'id' | 'name' | 'age'

リテラル型union(#4)が自動生成される形ですね。オブジェクトのキーを型レベルで扱えるようにする中核の道具です。

インデックスアクセス型 — T[K] #

T[K]は「T型のKキーに対応する値の型」です。

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

type IdType = User['id'];       // string
type NameType = User['name'];   // string
type AgeType = User['age'];     // number

オブジェクトアクセス構文(user.iduser['id'])を型レベルでもそのまま使える、ということです。

上のgetProperty<T, K extends keyof T>で起こること:

  1. 呼び出し側がgetProperty(user, 'id')で呼ぶ
  2. Tuserの型(User)として推論される
  3. K'id'として推論される
  4. 戻り値の型T[K]User['id'] = string

型変数同士がつながることで、getProperty(user, 'id')の戻り値の型が正確にstringになります。間違ったキー('foo')を渡すとK extends keyof T制約に違反してコンパイルエラー。

これがJavaScriptでは表現できない、TypeScriptの本当の表現力です。

ジェネリックインターフェースとtype alias #

関数だけでなく、型エイリアス自体もジェネリックにできます。

ジェネリックtype alias
type Box<T> = {
  value: T;
};

const a: Box<number> = { value: 42 };
const b: Box<string> = { value: 'hello' };

Boxはどんな型でも入れられる汎用コンテナです。使用時点でBox<number>のように型を埋めて具体化します。

interfaceも同様:

ジェネリックinterface
interface ApiResponse<T> {
  data: T;
  status: number;
  timestamp: number;
}

const userResp: ApiResponse<User> = {
  data: { id: 'u-1', name: 'チョルス' },
  status: 200,
  timestamp: Date.now(),
};

このパターンはAPIレスポンスのラッパー、結果コンテナなどで非常によく使われます。

Discriminated unionをジェネリックに #

#4で見た非同期状態のパターンをジェネリックに一般化:

ジェネリック結果型
type Result<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function divide(a: number, b: number): Result<number> {
  if (b === 0) return { ok: false, error: '0で割ることはできません' };
  return { ok: true, value: a / b };
}

const r = divide(10, 2);
if (r.ok) {
  console.log(r.value);   // r.value: number
} else {
  console.log(r.error);   // r.error: string
}

Result<T, E>T(成功値の型)とE(エラー型、デフォルト値string)の二つのパラメータを受け取ります。使用箇所で適切な型を埋めて具体化します。

このパターンはRustなどの言語からインスピレーションを受けた、型安全なエラー処理の方法です。throwの代わりに結果オブジェクトを返す方針です。大規模なコードベースで人気のあるスタイルです。

ジェネリッククラス #

クラスもジェネリックにできます。

ジェネリックStack
class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }

  get size(): number {
    return this.items.length;
  }
}

const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
const top = numStack.pop();   // top: number | undefined

const strStack = new Stack<string>();
strStack.push('hello');

同じStackクラスがnumber、string、どんな型でも入れられます。Tはインスタンス生成時点で決まります。

データ構造(Stack、Queue、LinkedList)の実装が、ジェネリッククラスの最も自然な使い所です。

条件付き型 — ちょっと味見 #

型自体で条件文が使えるというのが、面白い機能の一つです。

conditional types
type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>;   // true
type B = IsString<42>;         // false

T extends string ? true : falseは「Tがstringの部分集合ならtrue型、そうでなければfalse型」という意味です。

実用的な例:

配列要素の型抽出
type ArrayElement<T> = T extends (infer U)[] ? U : never;

type A = ArrayElement<number[]>;       // number
type B = ArrayElement<string[]>;       // string
type C = ArrayElement<{ x: 1 }[]>;     // { x: 1 }

infer Uは「型のパターンマッチング結果をUという名前で受け取る」という意味です。Tが何らかの配列型(U[])なら、その要素型をUとして抽出するということです。

条件付き型とinferはTypeScriptのメタプログラミングの領域です。ライブラリ作者や型ユーティリティを自分で作る人が主に使い、日常的によく使うことは少ないですが、知っておくと強力な道具です。

マップ型 (mapped types) #

既存の型の各プロパティを変換して新しい型を作る技法です。

マップ型
type ReadonlyUser = {
  readonly [K in keyof User]: User[K];
};

[K in keyof User]は「Userの各キーKについて」繰り返すという意味です。そして各プロパティにreadonlyを付けます。結果として、Userのすべてのプロパティがreadonlyになった型。

TypeScriptはこのようなパターンをよく使うことを知っているので、よく使う変換をユーティリティ型として事前に提供しています。

ユーティリティ型のプレビュー
type ReadonlyUser = Readonly<User>;
type PartialUser = Partial<User>;     // すべてのフィールドをオプショナルに
type RequiredUser = Required<User>;   // すべてのオプショナルを必須に

Readonly<T>Partial<T>のようなものがマップ型で定義されているということです。それぞれどう定義されているかは#7で扱います。

実戦パターン集 #

1. オブジェクトの値のunion型 #

値の型union
const ROLES = {
  ADMIN: 'admin',
  EDITOR: 'editor',
  VIEWER: 'viewer',
} as const;

type Role = typeof ROLES[keyof typeof ROLES];
// 'admin' | 'editor' | 'viewer'

typeof ROLESはROLESオブジェクトの型、keyof typeof ROLESはそのキー('ADMIN' | 'EDITOR' | 'VIEWER')、インデックスアクセスで値をunionにした結果です。

データを一箇所に置いて型をそこから自動的に導出する、非常に強力なパターンです。データと型の同期負担がゼロになります。

2. 関数の戻り値の型抽出 #

ReturnTypeの活用
function fetchUser() {
  return { id: 'u-1', name: 'チョルス', email: 'cheolsu@example.com' };
}

type User = ReturnType<typeof fetchUser>;
// { id: string; name: string; email: string }

関数が返すオブジェクトの形を型として抽出。別途型を定義しなくても、関数シグネチャから自動的に取得します。

3. 安全なオブジェクトキー変換 #

オブジェクトキー変換
function transform<T extends Record<string, any>, U>(
  obj: T,
  fn: <K extends keyof T>(value: T[K], key: K) => U
): Record<keyof T, U> {
  const result = {} as Record<keyof T, U>;
  for (const key in obj) {
    result[key] = fn(obj[key], key);
  }
  return result;
}

上のコードは一般化されたオブジェクト変換関数です。型はやや複雑ですが、ジェネリクス + 制約 + keyof + インデックスアクセス + Recordがすべて登場する良い総合例です。最初はすべての部分を理解できなくても大丈夫です。

よくある落とし穴 #

1. ジェネリクスが多すぎる #

🚫 過剰なジェネリクス
function add<A extends number, B extends number>(a: A, b: B): number {
  return a + b;
}

ABがnumberの部分集合であることに意味がありません。単にfunction add(a: number, b: number)で十分です。ジェネリクスは多様な型に一般化が必要なときだけ使ってください。

2. extendsの二つの意味の使い分け #

type IsString<T> = T extends string ? true : false;   // 条件付き型
function log<T extends string>(value: T): void {}     // 制約

同じextendsなのに、二つの文脈で意味が違います。

  • ジェネリックパラメータの横 (<T extends ...>): 制約
  • 条件付き型 (T extends ... ? ... : ...): 条件チェック

文法上の位置で区別すれば良いです。

3. anyとunknownを混同する #

ジェネリクスで未知の型を受け取るとき、どちらが良いか?

  • any — チェックを切る。危険。可能なら避けるべき
  • unknown — チェック強制。安全。narrowingが必要
  • T (ジェネリック) — 呼び出し側が型を決める。関数がその型を保存する

データをそのまま通す関数なら、ほぼ常にジェネリクスが正解です。anyunknownを使うと、呼び出し側が型情報を失います。

比較
function bad(value: any): any { return value; }
function good<T>(value: T): T { return value; }

const a = bad(42);    // a: any (型情報が消える)
const b = good(42);   // b: number (型情報が保存される)

自分でやってみる — ミニEventEmitter #

ジェネリクスの表現力を見せる小さな例です。イベント名とペイロード型をマッピングしたEventEmitterです。

event-emitter.ts
type EventMap = {
  click: { x: number; y: number };
  hover: { id: string };
  submit: { values: Record<string, string> };
};

type Listener<T> = (payload: T) => void;

class TypedEmitter<TEvents extends Record<string, any>> {
  private listeners: { [K in keyof TEvents]?: Listener<TEvents[K]>[] } = {};

  on<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): void {
    if (!this.listeners[event]) this.listeners[event] = [];
    this.listeners[event]!.push(listener);
  }

  emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void {
    this.listeners[event]?.forEach(l => l(payload));
  }
}

const emitter = new TypedEmitter<EventMap>();

emitter.on('click', payload => {
  console.log(payload.x, payload.y);   // payloadは自動で { x, y } 型
});

emitter.on('hover', payload => {
  console.log(payload.id);             // payloadは { id } 型
});

emitter.emit('click', { x: 10, y: 20 });   // ✓
emitter.emit('click', { id: 'u-1' });       // 🚫 型が合いません
emitter.emit('xxxxx', {});                  // 🚫 登録されていないイベント

イベント名とそれに合うペイロード型がEventMapに一度定義されると、onemitの呼び出しがすべてその定義に従って自動的に検査されます。間違ったペイロードをemitするとコンパイルエラー。

JavaScriptではコード + コメントで表現するしかなかったものが、型として強制されます。こういうところがTypeScriptの魅力です。

まとめ #

今回はジェネリクスの深い道具を扱いました。

  • 制約 (extends) — 型変数に条件を課す
  • 複数の型パラメータ — 変数間の関係を表現
  • keyof T — オブジェクトキーのunion型
  • インデックスアクセス T[K] — オブジェクトの特定キーの値の型
  • ジェネリックtype alias / interface / class — 型の形に変数を差し込む
  • conditional typesとinfer — 型レベルのパターンマッチング(ちょっと味見)
  • マップ型 — オブジェクトの各プロパティの変換

ここまで来ると、TypeScriptのほぼすべての表現力を手に入れたことになります。最後の一編が残っています。「TypeScript基礎講座 #7 ユーティリティ型とtsconfig」では、今回ちらっと触れたPartialPickOmitReturnTypeのような標準ユーティリティ型を一気に整理し、コンパイル動作を決めるtsconfig.jsonの主要オプションを扱ってシリーズを締めくくります。

X