TypeScript Generics 完全理解ガイド:混乱からマスターへ(実務パターン付き)
Genericsは、TypeScriptの型システムで最も強力な機能です。そして最も誤解されている機能でもあるんですよね。<T extends Record<string, unknown>, K extends keyof T> のような型シグネチャを見て頭がショートした経験、みんなあると思います。
実は、Genericsは複雑じゃないんです。型のための関数に過ぎません。これが一度ピンときたら、あとは全部繋がってきます。このガイドでは「Genericsなんとなく分かる」レベルから「型安全なユーティリティライブラリを書ける」レベルまで一気に引き上げます。
Genericsとは何か(60秒メンタルモデル)
Genericは型変数です。後から埋められる型のプレースホルダーですね。関数の引数が値のプレースホルダーなのと同じです。
普通の関数:
function identity(value: string): string { return value; }
これはstringでしか動きません。どんな型でも動くようにするには?
Genericsなし — 型情報が失われる:
function identity(value: any): any { return value; } const result = identity("hello"); // resultは'any' — 使い物にならない
Genericsあり — 型が流れていく:
function identity<T>(value: T): T { return value; } const result = identity("hello"); // resultは'string' ✅ const num = identity(42); // numは'number' ✅
Tがジェネリック型パラメータです。identity("hello")を呼ぶと、TypeScriptはT = stringと推論して、その情報を戻り値の型まで引き継ぎます。関数を一度書くだけで、どんな型でも正確に動作し、型安全性も維持されるわけです。
これが全部です。マジで。この先の内容は全部、この一つのアイデアの上に乗っかってるだけです。
基礎を超えて:Generic Constraints
制約なしのGenericsは何でも受け入れます。いつもそれでいいわけじゃないですよね。
問題:許容しすぎ
function getLength<T>(value: T): number { return value.length; // ❌ エラー: 'T'型に'length'プロパティは存在しない }
TypeScriptはTにlengthプロパティがあるか分かりません。numberかもしれないし、booleanかもしれない。
解決:extendsキーワード
function getLength<T extends { length: number }>(value: T): number { return value.length; // ✅ 動作 — Tに'length'があることが保証 } getLength("hello"); // ✅ stringにはlengthがある getLength([1, 2, 3]); // ✅ arrayにはlengthがある getLength(42); // ❌ エラー: numberにはlengthがない
extendsキーワードで制約をつけます。「Tには最低限このプロパティがあるはずだよ」とTypeScriptに教えるわけです。最低限のスペックだと思えば分かりやすいです。
実務パターン:APIレスポンスラッパー
interface ApiResponse<T> { data: T; status: number; timestamp: string; } interface User { id: string; name: string; email: string; } interface Product { id: string; title: string; price: number; } type UserResponse = ApiResponse<User>; // { data: User; status: number; timestamp: string; } type ProductResponse = ApiResponse<Product>; // { data: Product; status: number; timestamp: string; }
一つのインターフェースで無限に再利用可能。dataフィールドは各ユースケースに対して型安全です。
複数の型パラメータ
Genericsは複数のパラメータを持てます。関数に複数の引数があるのと同じですね:
function pair<A, B>(first: A, second: B): [A, B] { return [first, second]; } const result = pair("hello", 42); // [string, number]
実務パターン:型安全なイベントエミッター
type EventMap = { userLogin: { userId: string; timestamp: Date }; pageView: { url: string; referrer: string }; purchase: { productId: string; amount: number }; }; class TypedEventEmitter<Events extends Record<string, any>> { private handlers: Partial<{ [K in keyof Events]: Array<(payload: Events[K]) => void>; }> = {}; on<K extends keyof Events>( event: K, handler: (payload: Events[K]) => void ): void { if (!this.handlers[event]) { this.handlers[event] = []; } this.handlers[event]!.push(handler); } emit<K extends keyof Events>(event: K, payload: Events[K]): void { this.handlers[event]?.forEach((handler) => handler(payload)); } } const emitter = new TypedEventEmitter<EventMap>(); emitter.on("userLogin", (payload) => { console.log(payload.userId); // ✅ オートコンプリートが効く console.log(payload.timestamp); // ✅ 型安全 }); emitter.on("purchase", (payload) => { console.log(payload.amount); // ✅ number }); emitter.emit("pageView", { url: "/home", referrer: "google.com", }); // ✅ ペイロードの形状が検証される
このパターンを使うと、ランタイムバグがまるごと消えます。イベント名とペイロードの形が型レベルで紐付いているので、イベント名を変えたりペイロードの構造をいじったら、TSがビルド時に壊れたハンドラを全部見つけてくれます。
ジェネリックユーティリティ型:TypeScript組み込みのパワーツール
TypeScriptにはすべての開発者が知るべきジェネリックユーティリティ型が内蔵されています。今学んでいるのと同じGenericsパターンで作られたものです。
Partial<T> — 全プロパティをオプショナルに
interface User { name: string; email: string; age: number; } function updateUser(id: string, updates: Partial<User>): void { // updatesはUserプロパティの任意の組み合わせが可能 } updateUser("123", { name: "Alice" }); // ✅ updateUser("123", { email: "[email protected]" }); // ✅ updateUser("123", { invalid: true }); // ❌ エラー
Pick<T, K> — 特定のプロパティを選択
type UserPreview = Pick<User, "name" | "email">; // { name: string; email: string; }
Omit<T, K> — 特定のプロパティを除去
type CreateUserDto = Omit<User, "id" | "createdAt">; // UserからidとcreatedAt以外の全て
Record<K, V> — オブジェクト型を作成
type StatusMap = Record<"active" | "inactive" | "banned", User[]>; // { active: User[]; inactive: User[]; banned: User[]; }
ReturnType<T> — 関数の戻り値型を抽出
function createUser() { return { id: "1", name: "Alice", role: "admin" as const }; } type NewUser = ReturnType<typeof createUser>; // { id: string; name: string; role: "admin" }
Partialの内部実装
実際の実装コードです:
type Partial<T> = { [P in keyof T]?: T[P]; };
これはMapped Typeです。Tの全てのキーPをイテレートし、それぞれをオプショナル(?)にして、元の値の型(T[P])はそのまま保持します。これを理解すると、自分だけのユーティリティ型を作れるようになるんです。
条件型:型レベルのロジック
条件型を使うと、型システムでif/elseロジックが書けます:
type IsString<T> = T extends string ? "yes" : "no"; type A = IsString<string>; // "yes" type B = IsString<number>; // "no"
一見学問的に見えるかもしれませんが、実務でめちゃくちゃ使います。
実務パターン:APIレスポンスアンラッパー
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T; type A = UnwrapPromise<Promise<string>>; // string type B = UnwrapPromise<Promise<number>>; // number type C = UnwrapPromise<string>; // string(Promiseでなければそのまま)
inferキーワードはパターン内で型をキャプチャします。ここではPromise<>の内部型を取り出しているわけですね。
実務パターン:深いプロパティアクセス
type NestedValue<T, Path extends string> = Path extends `${infer Key}.${infer Rest}` ? Key extends keyof T ? NestedValue<T[Key], Rest> : never : Path extends keyof T ? T[Path] : never; interface Config { database: { host: string; port: number; credentials: { username: string; password: string; }; }; cache: { ttl: number; }; } type DbHost = NestedValue<Config, "database.host">; // string type DbUser = NestedValue<Config, "database.credentials.username">; // string type CacheTtl = NestedValue<Config, "cache.ttl">; // number
Lodashの_.get()やtRPCのパスベースAPIの裏側で、まさにこういう型が動いています。型システムがオブジェクト構造を再帰的に辿って、正確な戻り値型を導き出してくれるわけです。
Mapped Types:型をプログラム的に変換
Mapped Typesは既存の型を変換して新しい型を作ります:
// 全プロパティをreadonlyに type Readonly<T> = { readonly [P in keyof T]: T[P]; }; // 全プロパティをnullableに type Nullable<T> = { [P in keyof T]: T[P] | null; }; // 全プロパティを必須に(optionalを除去) type Required<T> = { [P in keyof T]-?: T[P]; };
実務パターン:フォームの状態型
interface UserForm { name: string; email: string; bio: string; } // 各フィールドのエラー状態を生成 type FormErrors<T> = { [K in keyof T]?: string; }; // 各フィールドのtouched状態を生成 type FormTouched<T> = { [K in keyof T]: boolean; }; const errors: FormErrors<UserForm> = { email: "メールアドレスの形式が正しくありません", }; const touched: FormTouched<UserForm> = { name: true, email: true, bio: false, };
フォームデータのインターフェースを一つ作るだけで、エラー状態もtouched状態も自動的に導出できます。UserFormにフィールドを追加したら、FormTouchedにも入れろとTSが教えてくれます。
テンプレートリテラル型:型レベルの文字列操作
TypeScriptは型システムで文字列を操作できます:
type EventName<T extends string> = `on${Capitalize<T>}`; type ClickEvent = EventName<"click">; // "onClick" type FocusEvent = EventName<"focus">; // "onFocus" type SubmitEvent = EventName<"submit">; // "onSubmit"
実務パターン:型安全なルートビルダー
type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}` ? Param | ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}` ? Param : never; type Params = ExtractParams<"/users/:userId/posts/:postId">; // "userId" | "postId" function buildUrl<T extends string>( template: T, params: Record<ExtractParams<T>, string> ): string { return Object.entries(params).reduce( (url, [key, value]) => url.replace(`:${key}`, value as string), template as string ); } buildUrl("/users/:userId/posts/:postId", { userId: "123", postId: "456", }); // ✅ buildUrl("/users/:userId/posts/:postId", { userId: "123", // ❌ エラー: 'postId'が不足 });
型システムがURLテンプレートをパースして、パラメータが足りないと即エラーにしてくれます。
satisfies演算子:型を検証するが広げない
TypeScript 4.9で入ったsatisfiesは、値が型に合ってるかチェックしつつ、推論された型はそのまま維持してくれます:
type Color = "red" | "green" | "blue"; type ColorMap = Record<Color, string | number[]>; // satisfiesなし — 型が広がる const colors1: ColorMap = { red: "#ff0000", green: [0, 255, 0], blue: "#0000ff", }; colors1.red.toUpperCase(); // ❌ エラー: number[]かもしれない // satisfiesあり — リテラル型が保持される const colors2 = { red: "#ff0000", green: [0, 255, 0], blue: "#0000ff", } satisfies ColorMap; colors2.red.toUpperCase(); // ✅ stringだと分かる colors2.green.map((c) => c * 2); // ✅ number[]だと分かる
satisfiesは両方のいいとこ取りです。型の制約チェックもできるし、型推論も精密なまま。
Genericsのベストプラクティス
1. 型パラメータに説明的な名前をつける
単一文字のパラメータはシンプルなGenericsには十分ですが、複雑なものには名前が必要です:
// ❌ 読みにくい function merge<A, B, C>(source: A, override: B, defaults: C): A & B & C; // ✅ ずっと明確 function merge< TSource, TOverride, TDefaults, >(source: TSource, override: TOverride, defaults: TDefaults): TSource & TOverride & TDefaults;
2. 不要なときはGenericsを使わない
// ❌ 不要なGeneric — Tが意味のある使われ方をしていない function greet<T extends string>(name: T): string { return `Hello, ${name}!`; } // ✅ 型を直接使う function greet(name: string): string { return `Hello, ${name}!`; }
型パラメータがシグネチャで一度しか現れないなら、Genericは不要な可能性が高いです。
3. ジェネリックパラメータにデフォルト値を使う
interface ApiResponse<T = unknown> { data: T; status: number; } const response: ApiResponse = { data: null, status: 200 }; const userResponse: ApiResponse<User> = { data: user, status: 200 };
4. assertではなくconstraintを使う
// ❌ 'as'で型を強制 function getProperty(obj: any, key: string) { return obj[key] as any; } // ✅ Genericsと制約を使う function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { name: "Alice", age: 30 }; getProperty(user, "name"); // ✅ stringを返す getProperty(user, "age"); // ✅ numberを返す getProperty(user, "email"); // ❌ エラー: "email"はkeyof userにない
よくある間違いとその対処法
間違い1:Genericsの過剰エンジニアリング
// ❌ 読めないし不要 type OverEngineered<T extends object, K extends keyof T, V extends T[K]> = { key: K; value: V; original: T; }; // ✅ 同じことをするシンプルなバージョン type PropertyEntry<T extends object> = { [K in keyof T]: { key: K; value: T[K]; original: T }; }[keyof T];
間違い2:ユニオン型での条件型の分配を忘れる
条件型はユニオンに対して分配されます:
type ToArray<T> = T extends any ? T[] : never; type Result = ToArray<string | number>; // string[] | number[] ((string | number)[]ではない!)
分配を防ぎたい場合は、両辺をタプルで包みます:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never; type Result = ToArrayNonDist<string | number>; // (string | number)[]
間違い3:const型パラメータを使わない
TypeScript 5.0以降はリテラル型保持のためのconst型パラメータをサポートしています:
// constなし — リテラルが広がる function createConfig<T>(config: T) { return config; } const c1 = createConfig({ mode: "production" }); // { mode: string } — stringに広がってしまう // constあり — リテラルが保持される function createConfig<const T>(config: T) { return config; } const c2 = createConfig({ mode: "production" }); // { mode: "production" } — リテラル型が保持 ✅
全部合わせて:型安全なクエリビルダー
実践的なものを作ってみましょう。複数のGenericsパターンを組み合わせた型安全なクエリビルダーです:
interface Schema { users: { id: string; name: string; email: string; age: number; role: "admin" | "user"; }; posts: { id: string; title: string; content: string; authorId: string; published: boolean; }; } type WhereClause<T> = { [K in keyof T]?: T[K] | { gt?: T[K]; lt?: T[K]; eq?: T[K] }; }; class QueryBuilder< TSchema extends Record<string, Record<string, any>>, TTable extends keyof TSchema = keyof TSchema, > { private table: TTable | null = null; private selectedFields: (keyof TSchema[TTable])[] = []; from<T extends keyof TSchema>(table: T): QueryBuilder<TSchema, T> { const qb = new QueryBuilder<TSchema, T>(); (qb as any).table = table; return qb; } select<K extends keyof TSchema[TTable]>( ...fields: K[] ): QueryBuilder<TSchema, TTable> { this.selectedFields = fields; return this; } where(clause: WhereClause<TSchema[TTable]>): QueryBuilder<TSchema, TTable> { return this; } build(): string { const fields = this.selectedFields.length > 0 ? this.selectedFields.join(", ") : "*"; return `SELECT ${fields} FROM ${String(this.table)}`; } } const db = new QueryBuilder<Schema>(); // 完全な型安全性 — オートコンプリートがどこでも動作 db.from("users") .select("name", "email") .where({ role: "admin", age: { gt: 18 } }); db.from("posts") .select("title", "published") .where({ published: true }); // ❌ これらは全てコンパイル時エラー: // db.from("users").select("title"); // usersに'title'は存在しない // db.from("posts").where({ role: "admin" }); // postsに'role'は存在しない
これがGenericsの威力です。クラス一つでスキーマ全テーブルをカバーできて、存在しないカラムのselectや無関係なフィールドのwhereは全部コンパイル時に弾いてくれます。ランタイムオーバーヘッドはゼロ。
いつGenericsを使うべきか
Genericsが輝くとき:
- 再利用可能なユーティリティを書くとき — 複数のデータ形状で使われる関数、クラス、型
- 型の関係性が重要なとき — 出力型が入力型に依存する場合
- APIを構築するとき — 利用者が独自の型を渡す公開インターフェース
anyを排除したいとき — Genericsはほぼ常により良い代替手段
Genericsをスキップするとき:
- 具体的な型で十分なとき —
Userだけを扱うならUserとそのまま型付け - 型情報を保持していないとき —
Tがパラメータにだけあって戻り値にないなら不要 - 可読性が犠牲になるとき — 同僚が型シグネチャを理解できないなら簡素化
まとめ
TypeScript Genericsは複雑になったときに後付けするオプション機能じゃないんですよ。型システム全体を回してる根本の仕組みなんですよね。Array<T>もPromise<T>もRecord<K, V>も、全部Genericsです。
メンタルモデルはシンプル:Genericsは型のための関数。型の入力を受け取り、制約と条件を通して処理し、型の出力を生成する。これが身につけば、構文の暗記をやめてパターンが見えるようになります。
まずはシンプルに:型パラメータ一つ、制約一つ。条件型やMapped Typesは問題が求めたときに使えばいいんです。そして常に自分に問いかけてください:「具体的な型で足りる?」足りるなら、Genericはスキップ。最高の型レベルコードは、必要な情報だけ保持する最もシンプルなコードですから。
TypeScriptの型システムはただのリンターじゃない。プログラミング言語の中のプログラミング言語です。Genericsはそれをプログラミングする方法なんです。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう