TypeScriptジェネリクス完全ガイド〜関数・クラス・React・実用パターン20選〜【2026年版】

TypeScriptを書いていて、anyを使えば動くが、それでは型の意味がない」と感じたことがある人ほど、ジェネリクスを使いこなすと一気に世界が変わります。ジェネリクスは「型を引数として受け取る」仕組みで、関数・クラス・型エイリアス・Reactコンポーネントなど、TypeScriptのあらゆる抽象化レイヤーを支える基盤機能です。本記事ではTypeScript 5.x準拠で、概念→関数→クラス→Conditional→Reactまでの20以上の実用パターンを、すべてコピペで動くコード付きで体系化します。

対象は20〜40代の現役Webエンジニアで、TypeScript完全実践ガイドで型システムの全体像を掴んだ後、「ジェネリクスだけは何度書いても自信が持てない」「extendskeyofの組み合わせが読めない」と感じている層を想定しています。本記事を読了すれば、ライブラリ実装レベルのRecord / ReturnType / Awaitedがなぜそう書けるのか、自分で再実装できる解像度まで到達できます。

  1. ジェネリクスとは何か:型を抽象化する「型の関数」
    1. any との根本的な違い
    2. ジェネリクスの3つの登場箇所
    3. 本記事で扱うパターン全体像
  2. 関数ジェネリクスの基本パターン
    1. #1 identity:全ての基本
    2. #2 配列ジェネリクス(first / last / sample)
    3. #3 複数型パラメータ:zip / pair
    4. #4 デフォルト型パラメータ
  3. extends 制約と keyof:型を絞り込む
    1. #5 extends で「length を持つ型」だけ受け取る
    2. #6 keyof:オブジェクトの「キーの型」を取り出す
    3. #7 複数キーを同時に取り出す pickMany
    4. #8 deepGet:ネストしたパスを型安全に取得
    5. #9 Promise を返す関数の型を伝搬
  4. クラス・インターフェースのジェネリクス
    1. #10 Stack<T>:典型的なデータ構造
    2. #11 Repository<T>:データアクセスの共通化
    3. #12 Result<T, E>:成功・失敗を型で表現
    4. #13 ジェネリックインターフェース:Comparator
  5. Conditional Types と infer:型レベルプログラミング
    1. #14 Conditional Types の基本
    2. #15 Extract と Exclude を再実装する
    3. #16 infer:Conditional の中で型を取り出す
    4. #17 ReturnType を自前実装
    5. #18 Parameters を自前実装
    6. #19 Awaited:ネストした Promise を剥がす
    7. #20 ConstructorParameters と InstanceType
  6. React と組み合わせるジェネリクス
    1. #21 useState<T>:明示と推論の使い分け
    2. #22 useReducer<State, Action>
    3. #23 Generic Component:<T> を持つ React コンポーネント
    4. #24 useFetch:カスタムフックで型を返す
    5. #25 forwardRef + Generics
    6. #26 typed callback props
  7. ライブラリ実装レベルの応用パターン
    1. #27 groupBy:keyof で動的なキーを安全に
    2. #28 createApi:エンドポイント定義から型を生やす
    3. #29 Builder Pattern with Generics
    4. #30 Branded Types:意味の違う string を型で区別
    5. #31 EventEmitter<Events>:イベント名と payload を厳密に紐付け
    6. #32 Memoize:高階関数の型を維持
    7. #33 Pipe:可変長の関数合成を型安全に
    8. #34 Mapped Types と組み合わせる Partial 再実装
    9. #35 DeepPartial:再帰的にネストもoptionalに
  8. よくある落とし穴と読み解き方
    1. #36 引数から推論できないと any 化する
    2. #37 型パラメータが「位置」によって意味が変わる
    3. #38 制約と推論の優先順位
    4. #39 型アサーション(as)よりジェネリクスで解く
    5. #40 ジェネリクスを使うべき場面・避けるべき場面
  9. 実務で効くチェックリスト
    1. #41 ESLint で機械的に検知
    2. #42 型テスト:Expect<Equal<A, B>>
  10. よくある質問(FAQ)
    1. Q1. ジェネリクスと any はどう使い分けますか?
    2. Q2. extends と implements の違いは?
    3. Q3. 型パラメータの命名は T, U, V でないとダメ?
    4. Q4. ジェネリクスは実行時に何かしますか?
    5. Q5. 型パラメータが多くなりすぎたときの対処は?
    6. Q6. infer はいつ使えるようになれば一人前ですか?
    7. Q7. React のジェネリックコンポーネントが書きづらいです。
  11. 独学が辛くなったら:体系的に学べる選択肢
  12. まとめ:ジェネリクスは「型の関数」と捉えれば怖くない

ジェネリクスとは何か:型を抽象化する「型の関数」

ジェネリクスを一言で言うと「型を引数として受け取り、型を返す関数のようなもの」です。通常の関数が値を受け取って値を返すように、ジェネリクスは型を受け取って型を返します。Array<T>Tがまさにそれで、Array<number>と書けば「numberの配列型」が返ります。

any との根本的な違い

anyでも動く」と思った瞬間、ジェネリクスの真価を理解する第一歩を逃しています。違いは「入った型と出る型の関係を保てるか」の一点に尽きます。

// ❌ any:入った型情報が失われる
function firstAny(arr: any[]): any {
  return arr[0];
}
const a = firstAny([1, 2, 3]); // a: any → number として扱えない
a.toUpperCase(); // 型エラーにならない(実行時に爆発)

// ✅ generic:入力と出力の関係を維持
function first<T>(arr: T[]): T {
  return arr[0];
}
const b = first([1, 2, 3]); // b: number(型推論)
const c = first(["x", "y"]); // c: string
b.toUpperCase(); // ← ちゃんと型エラーになる

ジェネリクスの3つの登場箇所

ジェネリクスは「関数 / クラス・インターフェース / 型エイリアス」の3箇所で宣言できます。書く位置によって意味が変わるので、まずは型シグネチャの読み方を統一しておきます。

// (1) 関数ジェネリクス:呼び出しごとに型が決まる
function identity<T>(value: T): T {
  return value;
}

// (2) クラス・インターフェースジェネリクス:インスタンス化時に型が決まる
interface Box<T> { value: T }
class Container<T> { constructor(public item: T) {} }

// (3) 型エイリアスジェネリクス:型レベルの関数
type Nullable<T> = T | null;
type Pair<A, B> = { first: A; second: B };
  • 関数:呼び出しごとに型が決まる(最頻出)
  • クラス・インターフェース:インスタンス化時に型が決まる(データ構造・Repository層)
  • 型エイリアス:型レベルの関数(ユーティリティ型・ライブラリ実装)

本記事で扱うパターン全体像

カテゴリパターン難易度頻出度
基本identity / 配列 / Promise★☆☆毎日
制約extends / keyof / 複数型パラメータ★★☆毎日
クラス系Repository / Result / Builder★★☆週1
ConditionalExtract / Exclude / infer★★★月1
組み込みReturnType / Awaited / Parameters★★☆毎日
ReactFC / useState / Generic Component★★☆毎日
応用Builder / Branded / 型推論ハック★★★月1

関数ジェネリクスの基本パターン

もっとも頻出するのが関数ジェネリクスです。「入力された型を出力の型に反映させたい」場面ではほぼ全てジェネリクスを使います。

#1 identity:全ての基本

// 受け取った値をそのまま返す。型もそのまま伝搬する
function identity<T>(value: T): T {
  return value;
}

const n = identity(42);        // n: 42(リテラル型として推論)
const s = identity("hello");   // s: "hello"
const o = identity({ x: 1 });  // o: { x: number }

// 明示的に型を渡すこともできる(通常は不要)
const m = identity<number>(42); // m: number

#2 配列ジェネリクス(first / last / sample)

// 配列の要素型をそのまま戻り値に反映
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

function last<T>(arr: T[]): T | undefined {
  return arr[arr.length - 1];
}

function sample<T>(arr: T[]): T | undefined {
  return arr[Math.floor(Math.random() * arr.length)];
}

const x = first([1, 2, 3]);          // x: number | undefined
const y = last(["a", "b", "c"]);     // y: string | undefined
const z = sample([true, false]);     // z: boolean | undefined

#3 複数型パラメータ:zip / pair

// 2つの異なる型を同時に扱う
function pair<A, B>(a: A, b: B): [A, B] {
  return [a, b];
}

const p1 = pair(1, "one");          // p1: [number, string]
const p2 = pair(true, { x: 1 });    // p2: [boolean, { x: number }]

// zip:2つの配列を組にする
function zip<A, B>(as: A[], bs: B[]): [A, B][] {
  const len = Math.min(as.length, bs.length);
  return Array.from({ length: len }, (_, i) => [as[i], bs[i]]);
}

const zipped = zip([1, 2, 3], ["a", "b", "c"]); // [number, string][]

#4 デフォルト型パラメータ

// 呼び出し側が型を省略した場合のフォールバック
function createState<T = string>(initial?: T): { value: T | undefined } {
  return { value: initial };
}

const s1 = createState();        // s1: { value: string | undefined }
const s2 = createState<number>(); // s2: { value: number | undefined }
const s3 = createState(42);       // s3: { value: number | undefined }(推論)

// 複数パラメータでも使える
type ApiResponse<T = unknown, E = Error> =
  | { ok: true; data: T }
  | { ok: false; error: E };

extends 制約と keyof:型を絞り込む

「何でも受け取れる」だけではジェネリクスは弱いです。extendsで「最低限満たすべき条件」を課すことで、関数内部でも安全にプロパティアクセスができるようになります。

#5 extends で「length を持つ型」だけ受け取る

// length プロパティがあるものに制約
function logLength<T extends { length: number }>(value: T): T {
  console.log(`length: ${value.length}`);
  return value;
}

logLength("hello");        // OK: string は length を持つ
logLength([1, 2, 3]);      // OK: array も length を持つ
logLength({ length: 10 }); // OK: 明示的に length プロパティを持つ
// logLength(42);          // ❌ Error: number は length を持たない

#6 keyof:オブジェクトの「キーの型」を取り出す

// オブジェクトと、その「実在するキー名」を受け取る
function pick<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Taro", email: "t@example.com" };
const id = pick(user, "id");       // id: number
const name = pick(user, "name");   // name: string
// pick(user, "age");              // ❌ "age" は keyof user に存在しない

#7 複数キーを同時に取り出す pickMany

// 標準ライブラリの Pick<T, K> と同じことを自前で
function pickMany<T extends object, K extends keyof T>(
  obj: T,
  keys: K[]
): Pick<T, K> {
  const result = {} as Pick<T, K>;
  for (const key of keys) {
    result[key] = obj[key];
  }
  return result;
}

const slim = pickMany(user, ["id", "name"]);
// slim: { id: number; name: string }(emailは含まれない)

#8 deepGet:ネストしたパスを型安全に取得

// 2階層までを型安全に取得する例
function get2<
  T extends object,
  K1 extends keyof T,
  K2 extends keyof T[K1]
>(obj: T, k1: K1, k2: K2): T[K1][K2] {
  return obj[k1][k2];
}

const config = {
  api: { url: "https://api.example.com", timeout: 5000 },
  ui: { theme: "dark" as const },
};

const url = get2(config, "api", "url");    // url: string
const theme = get2(config, "ui", "theme"); // theme: "dark"

#9 Promise を返す関数の型を伝搬

// fetch ラッパーで「レスポンスの型」を呼び出し側に明示させる
async function fetchJson<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()) as T;
}

type User = { id: number; name: string };
const user = await fetchJson<User>("/api/user/1");
console.log(user.name); // 型推論が効く

// より厳格にしたい場合は zod 等でランタイム検証も組み合わせる

クラス・インターフェースのジェネリクス

クラスにジェネリクスを使うと、「中身の型が違うだけで処理は同じ」というデータ構造やリポジトリ層を、型安全に再利用できます。

#10 Stack<T>:典型的なデータ構造

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 ns = new Stack<number>();
ns.push(1); ns.push(2);
const top = ns.pop(); // top: number | undefined

const ss = new Stack<string>();
ss.push("a");
// ss.push(1); // ❌ type error

#11 Repository<T>:データアクセスの共通化

interface HasId { id: string }

class Repository<T extends HasId> {
  private store = new Map<string, T>();

  save(entity: T): T {
    this.store.set(entity.id, entity);
    return entity;
  }
  findById(id: string): T | undefined { return this.store.get(id); }
  findAll(): T[] { return Array.from(this.store.values()); }
  delete(id: string): boolean { return this.store.delete(id); }
}

type Product = { id: string; name: string; price: number };
const products = new Repository<Product>();
products.save({ id: "p1", name: "Pen", price: 100 });
const p = products.findById("p1"); // p: Product | undefined

#12 Result<T, E>:成功・失敗を型で表現

// Rust 風の Result 型(例外を投げない設計)
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

function divide(a: number, b: number): Result<number, string> {
  return b === 0 ? err("division by zero") : ok(a / b);
}

const r = divide(10, 2);
if (r.ok) {
  console.log(r.value); // ここでは r.value: number として確定
} else {
  console.error(r.error); // ここでは r.error: string
}

#13 ジェネリックインターフェース:Comparator

interface Comparator<T> {
  compare(a: T, b: T): number;
}

const numberAsc: Comparator<number> = {
  compare: (a, b) => a - b,
};

const userByName: Comparator<{ name: string }> = {
  compare: (a, b) => a.name.localeCompare(b.name),
};

function sort<T>(items: T[], comparator: Comparator<T>): T[] {
  return [...items].sort(comparator.compare);
}

const sorted = sort([3, 1, 2], numberAsc); // [1, 2, 3]

Conditional Types と infer:型レベルプログラミング

ここからが「型でプログラミングする」領域です。T extends U ? X : Yの構文(Conditional Types)とinferを組み合わせると、TypeScriptの組み込みユーティリティ型を自前で書けるようになります。

組み込み型役割典型用途
ReturnType<T>関数の戻り値の型API ラッパー / Redux selector
Parameters<T>関数の引数列(タプル)高階関数 / mock 生成
Awaited<T>Promise の中身を再帰的に剥がすasync 関数の戻り値型
ConstructorParameters<T>コンストラクタ引数DI / ファクトリ関数
InstanceType<T>クラスのインスタンス型typeof Class からの逆引き
Extract / ExcludeUnion の絞り込み状態遷移 / ナロイング

#14 Conditional Types の基本

// IsString<T>:T が string なら true、それ以外なら false
type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // true
type B = IsString<42>;       // false
type C = IsString<string>;   // true

// 実用例:プロパティが optional かどうかを判定する型
type IsOptional<T, K extends keyof T> =
  undefined extends T[K] ? true : false;

#15 Extract と Exclude を再実装する

// Union からマッチするものだけ取り出す(Extract 同等)
type MyExtract<T, U> = T extends U ? T : never;

type E1 = MyExtract<"a" | "b" | "c", "a" | "c">; // "a" | "c"
type E2 = MyExtract<string | number | boolean, string | number>; // string | number

// Union から特定の型を除外(Exclude 同等)
type MyExclude<T, U> = T extends U ? never : T;

type X1 = MyExclude<"a" | "b" | "c", "a">; // "b" | "c"
type X2 = MyExclude<string | number, string>; // number

#16 infer:Conditional の中で型を取り出す

// 配列の要素型を取り出す ElementOf<T>
type ElementOf<T> = T extends (infer U)[] ? U : never;

type N = ElementOf<number[]>;       // number
type S = ElementOf<string[]>;       // string
type T = ElementOf<[1, "a", true]>; // 1 | "a" | true

// Promise の中身を剥がす UnwrapPromise<T>
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type P1 = UnwrapPromise<Promise<string>>; // string
type P2 = UnwrapPromise<number>;            // number(そのまま)

#17 ReturnType を自前実装

// 関数の戻り値の型を取り出す
type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;

function makeUser() {
  return { id: 1, name: "Taro" };
}

type User = MyReturnType<typeof makeUser>; // { id: number; name: string }

// 標準の ReturnType<T> と完全同等

#18 Parameters を自前実装

// 関数の引数列をタプル型として取り出す
type MyParameters<F> = F extends (...args: infer P) => any ? P : never;

function update(id: number, name: string, active: boolean) {}

type Args = MyParameters<typeof update>;
// Args: [id: number, name: string, active: boolean]

// 実用例:既存関数を「最後の引数だけ取り換えて」再利用
type LastArg<F> = MyParameters<F> extends [...any[], infer L] ? L : never;
type L = LastArg<typeof update>; // boolean

#19 Awaited:ネストした Promise を剥がす

// TS 4.5+ で組み込み済み。再帰的に Promise を剥がす
type MyAwaited<T> =
  T extends Promise<infer U>
    ? MyAwaited<U>
    : T;

type A1 = MyAwaited<Promise<string>>;                  // string
type A2 = MyAwaited<Promise<Promise<number>>>;          // number
type A3 = MyAwaited<Promise<Promise<Promise<boolean>>>>; // boolean

// 実用:async 関数の最終的な値の型を取り出す
async function loadUser() { return { id: 1 }; }
type Loaded = Awaited<ReturnType<typeof loadUser>>; // { id: number }

#20 ConstructorParameters と InstanceType

// コンストラクタ引数とインスタンス型を取り出す組み込み型
class HttpClient {
  constructor(public baseUrl: string, public timeout: number) {}
}

type CtorArgs = ConstructorParameters<typeof HttpClient>;
// [baseUrl: string, timeout: number]

type Instance = InstanceType<typeof HttpClient>;
// HttpClient

// ファクトリ関数で活用
function create<C extends new (...args: any[]) => any>(
  Cls: C,
  ...args: ConstructorParameters<C>
): InstanceType<C> {
  return new Cls(...args);
}

const client = create(HttpClient, "https://api.example.com", 3000);
// client: HttpClient

React と組み合わせるジェネリクス

Reactでジェネリクスを使う場面は驚くほど多く、useState の型・カスタムフック・汎用コンポーネント・forwardRefのいずれも「型をどれだけ伝搬させられるか」が保守性を決めます。

#21 useState<T>:明示と推論の使い分け

import { useState } from "react";

// パターンA:初期値から推論
const [count, setCount] = useState(0); // number

// パターンB:union 型を扱うときは明示が必要
type Status = "idle" | "loading" | "success" | "error";
const [status, setStatus] = useState<Status>("idle");

// パターンC:初期値 null だが後で User 型を入れる
type User = { id: number; name: string };
const [user, setUser] = useState<User | null>(null);

// パターンD:配列を扱う場合
const [tags, setTags] = useState<string[]>([]);

#22 useReducer<State, Action>

import { useReducer } from "react";

type State = { count: number };
type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "set"; payload: number };

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case "increment": return { count: state.count + 1 };
    case "decrement": return { count: state.count - 1 };
    case "set":       return { count: action.payload };
  }
};

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  return (
    <button onClick={() => dispatch({ type: "increment" })}>
      {state.count}
    </button>
  );
}

#23 Generic Component:<T> を持つ React コンポーネント

// items の要素型 T を渡したい場合、関数コンポーネントを <T> で書く
type ListProps<T> = {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  keyExtractor: (item: T) => string;
};

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// 利用側:T が User として推論される
type User = { id: string; name: string };
const users: User[] = [{ id: "1", name: "Taro" }];

<List
  items={users}
  keyExtractor={(u) => u.id}
  renderItem={(u) => <span>{u.name}</span>}
/>

#24 useFetch:カスタムフックで型を返す

import { useEffect, useState } from "react";

type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function useFetch<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({ status: "idle" });

  useEffect(() => {
    let cancelled = false;
    setState({ status: "loading" });
    fetch(url)
      .then((r) => r.json() as Promise<T>)
      .then((data) => !cancelled && setState({ status: "success", data }))
      .catch((error) => !cancelled && setState({ status: "error", error }));
    return () => { cancelled = true; };
  }, [url]);

  return state;
}

// 利用側
type Post = { id: number; title: string };
const result = useFetch<Post[]>("/api/posts");
if (result.status === "success") {
  result.data.forEach((p) => console.log(p.title)); // 型推論が効く
}

#25 forwardRef + Generics

import { forwardRef, useImperativeHandle, useRef } from "react";

// 注意:forwardRef はそのままだと Generic が剥がれるため、
// 型アサーションで <T> を保ったまま公開する書き方が定番
type SelectProps<T> = {
  options: T[];
  getLabel: (item: T) => string;
};

type SelectHandle = { focus: () => void };

const SelectInner = forwardRef(
  <T,>(props: SelectProps<T>, ref: React.Ref<SelectHandle>) => {
    const inputRef = useRef<HTMLSelectElement>(null);
    useImperativeHandle(ref, () => ({
      focus: () => inputRef.current?.focus(),
    }));
    return (
      <select ref={inputRef}>
        {props.options.map((opt, i) => (
          <option key={i}>{props.getLabel(opt)}</option>
        ))}
      </select>
    );
  }
);

// Generic を保ったまま公開
const Select = SelectInner as <T>(
  props: SelectProps<T> & { ref?: React.Ref<SelectHandle> }
) => React.ReactElement;

#26 typed callback props

// Modal が「閉じる時に何かの値を返す」場合に T を伝搬
type ModalProps<T> = {
  isOpen: boolean;
  onClose: (result: T) => void;
  children: React.ReactNode;
};

function Modal<T>({ isOpen, onClose, children }: ModalProps<T>) {
  if (!isOpen) return null;
  return <div className="modal">{children}</div>;
}

// 利用側:T が "ok" | "cancel" として伝わる
<Modal<"ok" | "cancel">
  isOpen
  onClose={(r) => console.log(r)} // r: "ok" | "cancel"
>
  ...
</Modal>

ライブラリ実装レベルの応用パターン

ここからは「自分でユーティリティライブラリを書きたいとき」「API レスポンスを型安全に扱いたいとき」に効くパターンです。実務でいきなり書ける必要はありませんが、読めるようになっておくと TanStack Query や Zod のソースが理解できます。

#27 groupBy:keyof で動的なキーを安全に

function groupBy<T, K extends keyof T>(
  items: T[],
  key: K
): Map<T[K], T[]> {
  const map = new Map<T[K], T[]>();
  for (const item of items) {
    const k = item[key];
    const list = map.get(k) ?? [];
    list.push(item);
    map.set(k, list);
  }
  return map;
}

type Order = { id: string; userId: string; total: number };
const orders: Order[] = [
  { id: "o1", userId: "u1", total: 100 },
  { id: "o2", userId: "u1", total: 200 },
  { id: "o3", userId: "u2", total: 50 },
];

const grouped = groupBy(orders, "userId");
// grouped: Map<string, Order[]>

#28 createApi:エンドポイント定義から型を生やす

type Endpoint<Req, Res> = {
  request: Req;
  response: Res;
};

type Endpoints = {
  "GET /users":     Endpoint<void, { id: number; name: string }[]>;
  "GET /users/:id": Endpoint<{ id: number }, { id: number; name: string }>;
  "POST /users":    Endpoint<{ name: string }, { id: number }>;
};

async function api<K extends keyof Endpoints>(
  endpoint: K,
  body: Endpoints[K]["request"]
): Promise<Endpoints[K]["response"]> {
  const res = await fetch(endpoint.split(" ")[1], {
    method: endpoint.split(" ")[0],
    body: body ? JSON.stringify(body) : undefined,
  });
  return res.json();
}

const users = await api("GET /users", undefined);
// users: { id: number; name: string }[]

const created = await api("POST /users", { name: "Taro" });
// created: { id: number }

#29 Builder Pattern with Generics

// 必須プロパティが揃うまで build() を呼べないようにする
class QueryBuilder<T extends object = {}> {
  constructor(private state: T = {} as T) {}

  set<K extends string, V>(key: K, value: V): QueryBuilder<T & { [P in K]: V }> {
    return new QueryBuilder({ ...this.state, [key]: value } as T & { [P in K]: V });
  }

  build(): T {
    return this.state;
  }
}

const q = new QueryBuilder()
  .set("table", "users")
  .set("limit", 10)
  .set("orderBy", "id")
  .build();
// q: { table: string; limit: number; orderBy: string }

#30 Branded Types:意味の違う string を型で区別

// 同じ string でも UserId と PostId を区別する
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;

const toUserId = (s: string): UserId => s as UserId;
const toPostId = (s: string): PostId => s as PostId;

function getUser(id: UserId) { /* ... */ }

const uid = toUserId("u1");
const pid = toPostId("p1");
getUser(uid);   // OK
// getUser(pid); // ❌ PostId は UserId として渡せない
// getUser("u1"); // ❌ string も渡せない

#31 EventEmitter<Events>:イベント名と payload を厳密に紐付け

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

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

  on<K extends keyof Events>(event: K, fn: Listener<Events[K]>): void {
    (this.listeners[event] ??= []).push(fn);
  }

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

type AppEvents = {
  login:  { userId: string };
  logout: void;
  error:  { code: number; message: string };
};

const bus = new Emitter<AppEvents>();
bus.on("login", ({ userId }) => console.log(userId)); // 型推論OK
bus.emit("error", { code: 500, message: "x" });
// bus.emit("login", { code: 1 }); // ❌ payload 型不一致

#32 Memoize:高階関数の型を維持

// 入力された関数の型をそのまま保ったままメモ化
function memoize<Args extends unknown[], Ret>(
  fn: (...args: Args) => Ret
): (...args: Args) => Ret {
  const cache = new Map<string, Ret>();
  return (...args: Args): Ret => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key)!;
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

const slowAdd = (a: number, b: number): number => a + b;
const fastAdd = memoize(slowAdd);
fastAdd(1, 2); // 計算
fastAdd(1, 2); // キャッシュヒット
// fastAdd("a", "b"); // ❌ 元の関数の型が維持されている

#33 Pipe:可変長の関数合成を型安全に

// 関数のチェーンを型推論しながら繋ぐ(2段までの簡易版)
function pipe<A, B>(fn1: (a: A) => B): (a: A) => B;
function pipe<A, B, C>(fn1: (a: A) => B, fn2: (b: B) => C): (a: A) => C;
function pipe<A, B, C, D>(
  fn1: (a: A) => B,
  fn2: (b: B) => C,
  fn3: (c: C) => D
): (a: A) => D;
function pipe(...fns: ((x: any) => any)[]) {
  return (x: any) => fns.reduce((acc, fn) => fn(acc), x);
}

const toUpperLength = pipe(
  (s: string) => s.toUpperCase(),
  (s: string) => s.length
);
const n = toUpperLength("hello"); // n: number = 5

#34 Mapped Types と組み合わせる Partial 再実装

// 標準ライブラリ実装と同等
type MyPartial<T> = { [K in keyof T]?: T[K] };
type MyRequired<T> = { [K in keyof T]-?: T[K] };
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };

type User = { id: number; name: string; email?: string };

type UserUpdate = MyPartial<User>;
// { id?: number; name?: string; email?: string }

type UserStrict = MyRequired<User>;
// { id: number; name: string; email: string }

type UserFrozen = MyReadonly<User>;
// { readonly id: number; readonly name: string; readonly email?: string }

#35 DeepPartial:再帰的にネストもoptionalに

type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

type Config = {
  api: { url: string; timeout: number };
  ui: { theme: "light" | "dark"; lang: string };
};

const partial: DeepPartial<Config> = {
  api: { url: "x" }, // timeout は省略可能
};
// 全レベルで optional になる

よくある落とし穴と読み解き方

ジェネリクスは強力ですが、書き慣れないうちは「エラーメッセージが読めない」「型推論が効かない」場面に必ずぶつかります。頻出パターンを整理します。

#36 引数から推論できないと any 化する

// T を引数で使っていないと推論できず any になる
function bad<T>(): T[] {
  return [];
}
const arr = bad(); // arr: unknown[] (strict) / any[] (loose)

// 解決:呼び出し側で型を明示するか、引数経由で T を渡す
const arr2 = bad<number>(); // arr2: number[]

function good<T>(seed: T): T[] {
  return [seed];
}
const arr3 = good(1); // arr3: number[]

#37 型パラメータが「位置」によって意味が変わる

// (1) 関数自体に <T>:呼び出しごとに T が決まる
function fn<T>(x: T): T { return x; }

// (2) 型エイリアスに <T>:型を作るときに T が決まる
type Fn<T> = (x: T) => T;
const f1: Fn<number> = (x) => x; // 呼び出しごとには変わらない
const f2: Fn<string> = (x) => x;

// (1) のほうが汎用性は高いが、シグネチャを共通化したいときは (2)

#38 制約と推論の優先順位

// T extends string でも、リテラル型が欲しいなら const 修飾子(TS 5.0+)
function tag<const T extends string>(name: T): T {
  return name;
}

const t1 = tag("hello");          // t1: "hello"(リテラル型)
// const 無しだと t1: string になる

// 同様に配列リテラルもリテラルとして取れる
function readonly<const T extends readonly unknown[]>(arr: T): T {
  return arr;
}
const r = readonly([1, "a", true]); // r: readonly [1, "a", true]

#39 型アサーション(as)よりジェネリクスで解く

// ❌ as で逃げると、後から型を壊しても気付けない
const userBad = JSON.parse(json) as User;

// ✅ ジェネリクスでパース関数自体を型安全にする
function parseJson<T>(text: string, validator: (v: unknown) => v is T): T {
  const value = JSON.parse(text);
  if (!validator(value)) throw new Error("invalid shape");
  return value;
}

const isUser = (v: unknown): v is User =>
  typeof v === "object" && v !== null && "id" in v && "name" in v;

const user = parseJson(json, isUser); // user: User、型と実体が一致

#40 ジェネリクスを使うべき場面・避けるべき場面

状況判断理由
入力と出力の型に依存関係がある使うany や union で表せない関係を保てる
同じ処理を複数の型で再利用したい使うライブラリ実装の基本
「何でも入る」だけが目的避けるunknown または any で十分
パラメータが1度しか使われない避ける制約として機能しない(ESLintで検知可能)
3つ以上の型パラメータが必要設計見直しAPI が複雑すぎるサイン

実務で効くチェックリスト

レビュー時・自分のコードを読み返すときに使えるチェックポイントを整理します。これを満たしていれば、ジェネリクスの「使えていない使い方」は概ね回避できます。

  • T を1ヶ所しか使っていない:制約として機能していない可能性大。anyで書き直してみて違いがなければ削除候補
  • 呼び出し側で毎回 <T> を明示している:引数で T を推論できる設計に変更
  • extends 制約が無い:関数本体で.length.idにアクセスしているのに制約が無いとエラーになるはず。extendsを足す
  • 戻り値が広すぎる:T | undefinedを返しているなら、NonNullable<T>や Conditional でケース分けできないか検討
  • as での型アサーションが目立つ:ジェネリクスを使えばas無しで書けるパターンがほとんど

#41 ESLint で機械的に検知

// .eslintrc / eslint.config.js
{
  rules: {
    // T が1回しか使われていないジェネリクスを警告
    "@typescript-eslint/no-unnecessary-type-arguments": "error",
    // 不要な型アサーションを警告
    "@typescript-eslint/no-unnecessary-type-assertion": "error",
    // any を禁止
    "@typescript-eslint/no-explicit-any": "error",
    // unknown を扱う際の絞り込み忘れを検知
    "@typescript-eslint/no-unsafe-assignment": "error",
  },
}

#42 型テスト:Expect<Equal<A, B>>

// 型レベルの単体テストを書いて、リファクタ時のデグレを防ぐ
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false;

type Expect<T extends true> = T;

// 自前の MyReturnType が標準と等しいかをコンパイル時に検証
type t1 = Expect<Equal<
  MyReturnType<() => number>,
  number
>>;

type t2 = Expect<Equal<
  MyPartial<{ a: 1; b: 2 }>,
  { a?: 1; b?: 2 }
>>;

// 失敗するとコンパイルエラーになるので CI で検出できる

よくある質問(FAQ)

Q1. ジェネリクスと any はどう使い分けますか?

A. 「入った型と出る型に関係があるかどうか」で決まります。入力と出力の型を独立に扱ってよいならany(またはunknown)、関係を維持したいならジェネリクスです。実務ではanyはほぼ使わず、まずunknownTを検討するのが安全です。詳細はTypeScript完全実践ガイドを参照ください。

Q2. extends と implements の違いは?

A. ジェネリクスの文脈ではextends「型を絞り込む制約」を意味します(クラスの継承とは別物)。T extends stringは「T は string またはそのサブタイプである」と読みます。クラスのimplementsはインターフェース実装で、ジェネリクスのextendsとは意味が異なります。

Q3. 型パラメータの命名は T, U, V でないとダメ?

A. 慣習として1文字大文字(T, U, V, K, R)が広く使われますが、役割が明確になるならTKeyTValueTResponseのような命名のほうが読みやすいです。型パラメータが3つ以上ある関数では、1文字命名は急速に可読性を落とすため積極的に名前を付けるべきです。

Q4. ジェネリクスは実行時に何かしますか?

A. 実行時には消えます。コンパイル後の JavaScript には<T>の情報は残らないため、typeof TT instanceofのような実行時判定はできません。実行時の型検証が必要な場合はZodtypeofの組み合わせ等で別途実装します。

Q5. 型パラメータが多くなりすぎたときの対処は?

A. 3つを超えたら「設計が複雑すぎる」サインです。対処は (1) 関数を分割する、(2) 型パラメータをオブジェクト型1つにまとめる、(3) デフォルト型パラメータを設定する、の3択です。とくに React の汎用コンポーネントでは、関連する型パラメータを1つのConfig型に集約すると一気に読みやすくなります。

Q6. infer はいつ使えるようになれば一人前ですか?

A. 業務コードを書く分にはinferを直接書く機会はほとんどなく、標準ライブラリのReturnTypeAwaitedを「使う」だけで十分です。ただしinferを「読める」ようになると、TanStack Query や trpc 等のライブラリのソースが理解できるようになり、エラーメッセージの解読速度が一段上がります。

Q7. React のジェネリックコンポーネントが書きづらいです。

A. forwardRefmemoでラップするとジェネリクス情報が剥がれるのが定番の落とし穴です。本記事の#25のように「内部関数で<T>を保ち、最後にasで公開型に整形する」イディオムが定番です。React 19 ではforwardRefが不要になりつつあり、関数コンポーネントに直接refを渡せるためこの問題は徐々に解消していきます。

独学が辛くなったら:体系的に学べる選択肢

ジェネリクスは「自分のコードがレビューでどう直されるか」を体験しないと一段抜けたところまで到達しません。独学でextendskeyofの組み合わせが直感的に書けない期間が続いているなら、現役エンジニアの伴走を検討する価値があります。

  • テックアカデミー:現役エンジニアの週2メンタリングで、自分のTypeScriptコードを直接レビューしてもらえる。フロントエンドコースで React + TypeScript を扱う
  • 侍エンジニア:オーダーメイドカリキュラムで「業務で書いているコードのレビュー」をテーマに進められる。転職保証コースもあり
  • DMM WEBCAMP:転職保証つき。Next.js + TypeScript の実務カリキュラムが充実
  • レバテックキャリア:すでに実務経験がある人向け。TypeScript + React 案件の年収レンジを確認するだけでも市場価値の把握になる

自走できる方はTypeScript完全実践ガイドで型システム全体像 →本記事でジェネリクス →各React Hooks完全実践ガイド系記事で実装パターン、の順で踏破するのが最短ルートです。

まとめ:ジェネリクスは「型の関数」と捉えれば怖くない

本記事ではTypeScriptジェネリクスを、概念 → 関数 → クラス → Conditional/infer → React → 応用の順で40超のパターンに分けて解説しました。一見複雑に見えるinferや Conditional Types も、「型を引数として受け取り型を返す関数」という視点に立てば、通常のプログラミングと同じ感覚で読み解けます。

  • 基本3パターン(identity / 配列 / Promise)を写経:これだけで日常業務の8割をカバー
  • extends と keyof で「制約付きジェネリクス」に進む:pick系を自前実装できれば中級の入り口
  • ReturnType / Awaited / Parameters を「使う」:自前実装よりまず標準ライブラリを使いこなす
  • React 連携は型推論を信じる:カスタムフックと汎用コンポーネントは型を伝搬させるのが基本
  • 3パラメータを超えたら設計を疑う:抽象化のしすぎはむしろ可読性を落とす

本記事のコード片はすべて TypeScript 5.x でコンパイル可能であることを確認しています。手元のエディタにコピペし、推論結果をhoverで確認しながら写経していくのが、ジェネリクスを最速で身につける方法です。今日からひとつのプロジェクトにanyを1個減らしてジェネリクスに置き換える」を続けてみてください。1ヶ月後、コードの安心感が確実に変わっているはずです。

コメント

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