TypeScriptのジェネリクス入門|型パラメータの使い方を実例で解説

kento_morota 24分で読めます

TypeScriptの型システムを活用するうえで、ジェネリクス(Generics)は避けて通れない重要な概念です。ジェネリクスを使えば、「型を引数として受け取る」ことで、型安全性を保ちながら柔軟で再利用可能なコードを書けるようになります。

本記事では、ジェネリクスの基本概念から、関数・クラス・インターフェースでの活用、型制約、ユーティリティ型まで、実践的なコード例とともに段階的に解説します。

ジェネリクスとは?なぜ必要なのか

ジェネリクスは「型のパラメータ化」を実現する仕組みです。具体的な型を後から指定できるようにすることで、汎用的かつ型安全なコードを書くことが可能になります。

ジェネリクスなしの問題

// 問題1: any を使うと型安全性が失われる
function getFirstElement(arr: any[]): any {
  return arr[0];
}
const first = getFirstElement([1, 2, 3]);
// first の型は any → 型情報が完全に失われる
console.log(first.toUpperCase()); // 実行時エラー(number に toUpperCase はない)

// 問題2: 型ごとに関数を定義すると重複が発生する
function getFirstString(arr: string[]): string {
  return arr[0];
}
function getFirstNumber(arr: number[]): number {
  return arr[0];
}
// 同じロジックなのに型が違うだけで2つの関数が必要...

ジェネリクスによる解決

// ジェネリクスなら1つの関数で型安全に対応できる
function getFirstElement<T>(arr: T[]): T {
  return arr[0];
}

const firstNum = getFirstElement([1, 2, 3]);        // number と推論
const firstStr = getFirstElement(["a", "b", "c"]);  // string と推論

console.log(firstNum.toFixed(2));    // OK: number のメソッド
console.log(firstStr.toUpperCase()); // OK: string のメソッド
// console.log(firstNum.toUpperCase()); // コンパイルエラー!

<T>が「型パラメータ」であり、関数呼び出し時に具体的な型が決まります。Tは慣例的に使われる名前ですが、任意の名前を使用できます。

関数でのジェネリクス

基本的な使い方

// 単一の型パラメータ
function identity<T>(value: T): T {
  return value;
}

// 明示的に型を指定
const result1 = identity<string>("hello");
// 型推論に任せる(推奨)
const result2 = identity(42); // number と推論

// 複数の型パラメータ
function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

const p = pair("name", 42); // [string, number]

実用的な関数の例

// 配列からユニークな値を取得する
function unique<T>(arr: T[]): T[] {
  return [...new Set(arr)];
}

const nums = unique([1, 2, 2, 3, 3, 3]); // number[]
const strs = unique(["a", "b", "a"]);     // string[]

// オブジェクトから指定したキーの値を取得する
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>;
  keys.forEach((key) => {
    result[key] = obj[key];
  });
  return result;
}

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

const user: User = { id: 1, name: "田中", email: "tanaka@example.com", age: 30 };
const nameAndEmail = pick(user, ["name", "email"]);
// 型: Pick<User, "name" | "email"> = { name: string; email: string }

// map関数の型安全版
function typedMap<T, U>(arr: T[], fn: (item: T, index: number) => U): U[] {
  return arr.map(fn);
}

const lengths = typedMap(["hello", "world"], (s) => s.length);
// number[] と推論される

インターフェース・型エイリアスでのジェネリクス

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

// APIレスポンスのジェネリック型
interface ApiResponse<T> {
  success: boolean;
  data: T;
  error?: string;
  timestamp: number;
}

// 使用例:型パラメータに具体的な型を指定
interface User {
  id: number;
  name: string;
}

interface Product {
  id: number;
  title: string;
  price: number;
}

const userResponse: ApiResponse<User> = {
  success: true,
  data: { id: 1, name: "田中" },
  timestamp: Date.now(),
};

const productResponse: ApiResponse<Product[]> = {
  success: true,
  data: [
    { id: 1, title: "商品A", price: 1000 },
    { id: 2, title: "商品B", price: 2000 },
  ],
  timestamp: Date.now(),
};

ページネーション付きレスポンス

interface PaginatedResponse<T> {
  items: T[];
  pagination: {
    currentPage: number;
    totalPages: number;
    totalItems: number;
    perPage: number;
  };
}

// 使用例
type UserListResponse = PaginatedResponse<User>;

const response: UserListResponse = {
  items: [
    { id: 1, name: "田中" },
    { id: 2, name: "佐藤" },
  ],
  pagination: {
    currentPage: 1,
    totalPages: 5,
    totalItems: 100,
    perPage: 20,
  },
};

ジェネリック型エイリアス

// Result型(RustやElmのパターン)
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

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

const result = divide(10, 3);
if (result.ok) {
  console.log(result.value.toFixed(2)); // "3.33"
} else {
  console.error(result.error);
}

// Nullable型
type Nullable<T> = T | null;

function findUser(id: number): Nullable<User> {
  const users: User[] = [{ id: 1, name: "田中" }];
  return users.find((u) => u.id === id) ?? null;
}

クラスでのジェネリクス

// ジェネリッククラスの基本
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;
  }

  isEmpty(): boolean {
    return this.items.length === 0;
  }

  toArray(): T[] {
    return [...this.items];
  }
}

// 使用例
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
const top = numberStack.pop(); // number | undefined

const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");

リポジトリパターンの実装

// エンティティの基底型
interface Entity {
  id: number;
  createdAt: Date;
  updatedAt: Date;
}

// ジェネリックリポジトリ
class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();
  private nextId = 1;

  create(data: Omit<T, "id" | "createdAt" | "updatedAt">): T {
    const now = new Date();
    const item = {
      ...data,
      id: this.nextId++,
      createdAt: now,
      updatedAt: now,
    } as T;
    this.items.set(item.id, item);
    return item;
  }

  findById(id: number): T | undefined {
    return this.items.get(id);
  }

  findAll(): T[] {
    return Array.from(this.items.values());
  }

  update(id: number, data: Partial<Omit<T, "id" | "createdAt">>): T | undefined {
    const existing = this.items.get(id);
    if (!existing) return undefined;

    const updated = {
      ...existing,
      ...data,
      updatedAt: new Date(),
    } as T;
    this.items.set(id, updated);
    return updated;
  }

  delete(id: number): boolean {
    return this.items.delete(id);
  }
}

// 具体的なエンティティ
interface UserEntity extends Entity {
  name: string;
  email: string;
}

interface ProductEntity extends Entity {
  title: string;
  price: number;
}

// 使用例
const userRepo = new Repository<UserEntity>();
const user = userRepo.create({ name: "田中", email: "tanaka@example.com" });
console.log(user.id);   // 1
console.log(user.name);  // "田中"

const productRepo = new Repository<ProductEntity>();
const product = productRepo.create({ title: "商品A", price: 1000 });

型制約(Constraints)

ジェネリクスに制約を加えることで、型パラメータが満たすべき条件を指定できます。

extendsによる制約

// T は length プロパティを持つ型に制限される
function getLength<T extends { length: number }>(value: T): number {
  return value.length;
}

getLength("hello");     // OK: string は length を持つ
getLength([1, 2, 3]);   // OK: 配列は length を持つ
// getLength(42);        // Error: number に length はない

// keyof を使った制約
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "田中", age: 30, email: "tanaka@example.com" };
const name = getProperty(user, "name");   // string
const age = getProperty(user, "age");     // number
// getProperty(user, "phone");             // Error: "phone" は User のキーではない

条件型(Conditional Types)

// 条件に基づいて型を切り替える
type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // true
type B = IsString<number>;  // false

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

type NumElement = ElementOf<number[]>;  // number
type StrElement = ElementOf<string[]>;  // string

// Promiseの中身の型を取り出す
type Awaited<T> = T extends Promise<infer U> ? U : T;

type ResolvedType = Awaited<Promise<string>>;  // string

ユーティリティ型の活用

TypeScriptには、ジェネリクスを活用した組み込みのユーティリティ型が多数用意されています。

よく使うユーティリティ型

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
  role: "admin" | "member";
}

// Partial: すべてのプロパティをオプションにする
type UserUpdate = Partial<User>;
// { id?: number; name?: string; email?: string; age?: number; role?: "admin" | "member" }

// Required: すべてのプロパティを必須にする
type StrictUser = Required<User>;

// Readonly: すべてのプロパティを読み取り専用にする
type ImmutableUser = Readonly<User>;

// Pick: 指定したプロパティのみ抽出する
type UserSummary = Pick<User, "id" | "name">;
// { id: number; name: string }

// Omit: 指定したプロパティを除外する
type UserCreateInput = Omit<User, "id">;
// { name: string; email: string; age: number; role: "admin" | "member" }

// Record: キーと値の型を指定した辞書型
type UserMap = Record<number, User>;
type StatusMessage = Record<"success" | "error" | "loading", string>;

ユーティリティ型の実践的な活用例

// API関連の型定義
interface Task {
  id: number;
  title: string;
  description: string;
  status: "todo" | "in_progress" | "done";
  assignee: string;
  createdAt: string;
  updatedAt: string;
}

// 作成時の型(id, createdAt, updatedAt はサーバーが生成)
type CreateTaskInput = Omit<Task, "id" | "createdAt" | "updatedAt">;

// 更新時の型(すべてオプション、ただし id は変更不可)
type UpdateTaskInput = Partial<Omit<Task, "id" | "createdAt" | "updatedAt">>;

// 一覧表示用の型(一部のフィールドのみ)
type TaskListItem = Pick<Task, "id" | "title" | "status" | "assignee">;

// CRUD操作の型定義
interface TaskService {
  create(input: CreateTaskInput): Promise<Task>;
  update(id: number, input: UpdateTaskInput): Promise<Task>;
  delete(id: number): Promise<void>;
  getById(id: number): Promise<Task | null>;
  getAll(): Promise<TaskListItem[]>;
}

カスタムユーティリティ型の作成

// DeepReadonly: ネストしたオブジェクトも再帰的にReadonlyにする
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

// NonNullableProperties: nullableなプロパティからnullを除去
type NonNullableProperties<T> = {
  [P in keyof T]: NonNullable<T[P]>;
};

// RequiredKeys: 特定のキーだけ必須にする
type RequiredKeys<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;

// 使用例
interface FormData {
  name?: string;
  email?: string;
  age?: number;
}

type FormSubmitData = RequiredKeys<FormData, "name" | "email">;
// { age?: number; name: string; email: string }

実践:型安全なイベントシステムの構築

ジェネリクスの総合的な活用例として、型安全なイベントシステムを実装します。

// イベントマップの定義
interface EventMap {
  "user:login": { userId: number; timestamp: Date };
  "user:logout": { userId: number };
  "item:created": { itemId: number; title: string };
  "item:deleted": { itemId: number };
  "error": { message: string; code: number };
}

// 型安全なイベントエミッター
class TypedEventEmitter<T extends Record<string, unknown>> {
  private listeners: {
    [K in keyof T]?: Array<(payload: T[K]) => void>;
  } = {};

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

  off<K extends keyof T>(event: K, listener: (payload: T[K]) => void): void {
    const eventListeners = this.listeners[event];
    if (eventListeners) {
      this.listeners[event] = eventListeners.filter((l) => l !== listener) as typeof eventListeners;
    }
  }

  emit<K extends keyof T>(event: K, payload: T[K]): void {
    const eventListeners = this.listeners[event];
    if (eventListeners) {
      eventListeners.forEach((listener) => listener(payload));
    }
  }
}

// 使用例
const emitter = new TypedEventEmitter<EventMap>();

// イベント名とペイロードの型が自動的にチェックされる
emitter.on("user:login", (payload) => {
  // payload は { userId: number; timestamp: Date } と推論される
  console.log(`ユーザー ${payload.userId} がログインしました`);
});

emitter.on("error", (payload) => {
  // payload は { message: string; code: number } と推論される
  console.error(`[Error ${payload.code}] ${payload.message}`);
});

// 型安全なemit
emitter.emit("user:login", { userId: 1, timestamp: new Date() });
emitter.emit("error", { message: "Not Found", code: 404 });

// コンパイルエラー: 存在しないイベント名
// emitter.emit("unknown:event", {});

// コンパイルエラー: ペイロードの型が一致しない
// emitter.emit("user:login", { userId: "abc" });

この実装では、イベント名を指定するだけでペイロードの型が自動的に推論されます。存在しないイベント名や、型が合わないペイロードを渡そうとするとコンパイル時にエラーとして検出されるため、実行時のバグを大幅に削減できます。

まとめ

本記事では、TypeScriptのジェネリクスについて基本から応用までを解説しました。

  • ジェネリクスは「型を引数として受け取る」仕組みで、型安全性と再利用性を両立する
  • 関数、インターフェース、クラスのいずれでもジェネリクスを活用できる
  • extendsによる型制約で、型パラメータに条件を付けられる
  • Partial、Pick、Omit、Recordなどのユーティリティ型はジェネリクスの実用例そのもの
  • 条件型やinferを使えば、より高度な型の推論・変換が実現可能

ジェネリクスは最初は抽象的で難しく感じるかもしれませんが、実際のコードで使い始めると、その威力をすぐに実感できます。まずはApiResponse<T>のようなシンプルなジェネリック型の定義から始め、徐々にユーティリティ型やカスタム型の作成にステップアップしていくことをおすすめします。

#TypeScript#ジェネリクス#型
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう