はじめに

TypeScriptのジェネリクスを使いこなすには、基本構文を理解するだけでは不十分です。実際のプロジェクトでは、型パラメータに適切な制約を設け、複数の型パラメータ間の関係を表現し、条件に応じて型を分岐させるといった高度なテクニックが求められます。

前回の「TypeScriptジェネリクス入門」では、ジェネリクスの基本構文とextendsを使った単純な制約について解説しました。本記事では、その知識を土台として、より実践的で複雑な型関係を表現するテクニックを深掘りします。

本記事を読み終えると、以下のことができるようになります。

  • extends制約を応用して複雑な型条件を表現できる
  • keyof演算子を活用した型安全なプロパティアクセスを実装できる
  • デフォルト型パラメータを適切に設計できる
  • 複数の型パラメータを組み合わせた高度な型定義を作成できる
  • 条件付き型の基礎を理解し、型の分岐処理を実装できる

実行環境・前提条件

前提知識

動作確認環境

ツール バージョン
Node.js 20.x以上
TypeScript 5.7以上
VS Code 最新版

本記事のサンプルコードは、TypeScript Playgroundで動作確認できます。ローカル環境で実行する場合は、開発環境構築ガイドを参照してください。

期待される結果

本記事のコードを実行すると、以下の動作を確認できます。

  • 制約付きジェネリクスが不適切な型引数を拒否する
  • keyof制約により存在しないプロパティへのアクセスがコンパイルエラーになる
  • 条件付き型が入力に応じて適切な型を返す

TypeScriptジェネリクス制約(extends)の応用

前回の入門記事では、extendsキーワードを使った基本的な型制約を学びました。ここでは、より高度な制約パターンを紹介します。

複数インターフェースによる制約

交差型(&)を使って、複数の条件を同時に満たす型のみを受け入れるよう制約できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 識別子を持つ型
interface Identifiable {
  id: string | number;
}

// タイムスタンプを持つ型
interface Timestamped {
  createdAt: Date;
  updatedAt: Date;
}

// 名前を持つ型
interface Named {
  name: string;
}

// 複数の制約を組み合わせた関数
function logEntity<T extends Identifiable & Named>(entity: T): void {
  console.log(`[${entity.id}] ${entity.name}`);
}

// 使用例
interface User {
  id: number;
  name: string;
  email: string;
}

const user: User = { id: 1, name: "Alice", email: "alice@example.com" };
logEntity(user); // "[1] Alice"

// 制約を満たさない型はエラー
interface Product {
  sku: string;
  price: number;
}

const product: Product = { sku: "ABC-123", price: 1000 };
// logEntity(product); // エラー: ProductはIdentifiableとNamedを満たさない

3つ以上の制約を組み合わせる

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 監査可能なエンティティの更新関数
function updateAuditableEntity<T extends Identifiable & Named & Timestamped>(
  entity: T,
  updates: Partial<Omit<T, "id" | "createdAt" | "updatedAt">>
): T {
  return {
    ...entity,
    ...updates,
    updatedAt: new Date()
  };
}

// すべての制約を満たすエンティティ
interface Article {
  id: number;
  name: string;
  content: string;
  createdAt: Date;
  updatedAt: Date;
}

const article: Article = {
  id: 1,
  name: "TypeScript入門",
  content: "本記事では...",
  createdAt: new Date("2026-01-01"),
  updatedAt: new Date("2026-01-01")
};

const updated = updateAuditableEntity(article, { name: "TypeScript実践" });
console.log(updated.name);       // "TypeScript実践"
console.log(updated.updatedAt);  // 現在の日時
console.log(updated.createdAt);  // 元のまま(2026-01-01)

コンストラクタ型による制約

クラスのコンストラクタを型パラメータとして受け取る場合、newシグネチャを使って制約を定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// コンストラクタ型の定義
type Constructor<T> = new (...args: any[]) => T;

// 基底クラス
class Entity {
  id: number;
  
  constructor(id: number) {
    this.id = id;
  }
}

// ファクトリ関数
function createInstance<T extends Entity>(
  ctor: Constructor<T>,
  id: number
): T {
  return new ctor(id);
}

// 派生クラス
class User extends Entity {
  name: string = "";
}

class Product extends Entity {
  price: number = 0;
}

// 使用例
const user = createInstance(User, 1);
console.log(user.id);   // 1
console.log(user.name); // ""

const product = createInstance(Product, 100);
console.log(product.id);    // 100
console.log(product.price); // 0

// Entityを継承していないクラスはエラー
class NotEntity {
  value: string = "";
}
// createInstance(NotEntity, 1); // エラー

プリミティブ型による制約

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 数値型のみを受け入れる関数
function sum<T extends number>(values: T[]): number {
  return values.reduce((acc, val) => acc + val, 0);
}

console.log(sum([1, 2, 3, 4, 5])); // 15

// 文字列型のみを受け入れる関数
function concatenate<T extends string>(values: T[]): string {
  return values.join("");
}

console.log(concatenate(["Hello", " ", "World"])); // "Hello World"

// リテラル型を維持する例
function identity<T extends string | number>(value: T): T {
  return value;
}

const str = identity("hello" as const); // 型: "hello"
const num = identity(42 as const);      // 型: 42

TypeScriptのkeyof演算子とジェネリクス制約

keyof演算子は、オブジェクト型からキーのユニオン型を抽出します。ジェネリクスと組み合わせることで、型安全なプロパティアクセスを実現できます。

keyof演算子の基本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
interface Person {
  name: string;
  age: number;
  email: string;
}

// Personのキーを取得
type PersonKeys = keyof Person; // "name" | "age" | "email"

// keyofを使った安全なプロパティアクセス
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person: Person = {
  name: "Alice",
  age: 30,
  email: "alice@example.com"
};

const name = getProperty(person, "name");   // 型: string
const age = getProperty(person, "age");     // 型: number
const email = getProperty(person, "email"); // 型: string

console.log(name);  // "Alice"
console.log(age);   // 30
console.log(email); // "alice@example.com"

// 存在しないキーはコンパイルエラー
// getProperty(person, "address"); // エラー: "address"はPersonのキーではない

複数のキーを指定する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 指定したキーだけを持つオブジェクトを作成
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;
  password: string;
  role: string;
}

const user: User = {
  id: 1,
  name: "Alice",
  email: "alice@example.com",
  password: "secret123",
  role: "admin"
};

// 公開可能な情報のみを抽出
const publicUser = pick(user, ["id", "name", "email"]);
console.log(publicUser);
// { id: 1, name: "Alice", email: "alice@example.com" }

// password と role は含まれない
// console.log(publicUser.password); // エラー: passwordは存在しない

指定したキーを除外する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 指定したキーを除外したオブジェクトを作成
function omit<T extends object, K extends keyof T>(
  obj: T,
  keys: K[]
): Omit<T, K> {
  const result = { ...obj };
  keys.forEach(key => {
    delete result[key];
  });
  return result as Omit<T, K>;
}

const userWithoutCredentials = omit(user, ["password"]);
console.log(userWithoutCredentials);
// { id: 1, name: "Alice", email: "alice@example.com", role: "admin" }

プロパティの値を更新する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 特定のプロパティを安全に更新
function setProperty<T, K extends keyof T>(
  obj: T,
  key: K,
  value: T[K]
): T {
  return { ...obj, [key]: value };
}

const updatedPerson = setProperty(person, "age", 31);
console.log(updatedPerson.age); // 31

// 型が一致しない値はエラー
// setProperty(person, "age", "thirty-one"); // エラー: stringはnumberに代入できない

ネストしたプロパティへのアクセス

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// ネストしたオブジェクトの型
interface Company {
  name: string;
  address: {
    city: string;
    country: string;
  };
  employees: {
    count: number;
    departments: string[];
  };
}

// パスを使ったプロパティアクセス(2階層まで対応)
function getNestedProperty<
  T,
  K1 extends keyof T,
  K2 extends keyof T[K1]
>(obj: T, key1: K1, key2: K2): T[K1][K2] {
  return obj[key1][key2];
}

const company: Company = {
  name: "TechCorp",
  address: {
    city: "Tokyo",
    country: "Japan"
  },
  employees: {
    count: 100,
    departments: ["Engineering", "Sales", "Marketing"]
  }
};

const city = getNestedProperty(company, "address", "city");
console.log(city); // "Tokyo"(型: string)

const count = getNestedProperty(company, "employees", "count");
console.log(count); // 100(型: number)

TypeScriptジェネリクスのデフォルト型パラメータ

デフォルト型パラメータを使用すると、型引数を省略した場合のデフォルト値を指定できます。

デフォルト型パラメータの構文

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 基本構文
type Container<T = string> = {
  value: T;
};

// 型引数を省略するとデフォルト(string)
const stringContainer: Container = { value: "hello" };

// 明示的に指定することも可能
const numberContainer: Container<number> = { value: 42 };
const booleanContainer: Container<boolean> = { value: true };

console.log(stringContainer.value);  // "hello"
console.log(numberContainer.value);  // 42
console.log(booleanContainer.value); // true

制約とデフォルト型の組み合わせ

デフォルト型は、制約を満たす必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 制約とデフォルト型を組み合わせる
interface HasLength {
  length: number;
}

type Measurable<T extends HasLength = string> = {
  item: T;
  getLength(): number;
};

// デフォルト(string)を使用
const stringMeasurable: Measurable = {
  item: "TypeScript",
  getLength() {
    return this.item.length;
  }
};

console.log(stringMeasurable.getLength()); // 10

// 配列を指定
const arrayMeasurable: Measurable<number[]> = {
  item: [1, 2, 3, 4, 5],
  getLength() {
    return this.item.length;
  }
};

console.log(arrayMeasurable.getLength()); // 5

複数のデフォルト型パラメータ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// APIレスポンスの型
type ApiResponse<
  TData = unknown,
  TError = Error,
  TMeta = Record<string, unknown>
> = {
  success: boolean;
  data?: TData;
  error?: TError;
  meta?: TMeta;
};

// デフォルト型をすべて使用
const simpleResponse: ApiResponse = {
  success: true,
  data: { message: "OK" }
};

// 一部だけ指定
interface User {
  id: number;
  name: string;
}

const userResponse: ApiResponse<User> = {
  success: true,
  data: { id: 1, name: "Alice" }
};

// すべて指定
interface ApiError {
  code: string;
  message: string;
}

interface PaginationMeta {
  page: number;
  totalPages: number;
  totalCount: number;
}

const paginatedResponse: ApiResponse<User[], ApiError, PaginationMeta> = {
  success: true,
  data: [
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" }
  ],
  meta: {
    page: 1,
    totalPages: 10,
    totalCount: 100
  }
};

console.log(paginatedResponse.meta?.totalCount); // 100

デフォルト型パラメータの順序

必須の型パラメータの後にデフォルト型パラメータを配置する必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 正しい: 必須パラメータが先
type Wrapper<TRequired, TOptional = null> = {
  value: TRequired;
  extra: TOptional;
};

const wrapper1: Wrapper<string> = { value: "hello", extra: null };
const wrapper2: Wrapper<string, number> = { value: "hello", extra: 42 };

// 間違い: デフォルトパラメータを必須パラメータより前に配置
// type Invalid<TOptional = null, TRequired> = { ... }; // エラー

イベントハンドラの型定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// イベントハンドラの汎用型
type EventHandler<TEvent = Event, TReturn = void> = (event: TEvent) => TReturn;

// デフォルトのEventHandler
const handleClick: EventHandler = (event) => {
  console.log("Clicked:", event.type);
};

// カスタムイベント型を指定
interface MouseEvent {
  type: string;
  x: number;
  y: number;
  button: number;
}

const handleMouseMove: EventHandler<MouseEvent> = (event) => {
  console.log(`Position: (${event.x}, ${event.y})`);
};

// 戻り値の型も指定
const handleSubmit: EventHandler<Event, boolean> = (event) => {
  console.log("Form submitted");
  return true;
};

console.log(handleSubmit({ type: "submit" } as Event)); // true

TypeScript複数型パラメータの実践テクニック

複数の型パラメータを効果的に使用することで、入力と出力の関係、キーと値の関係など、複雑な型関係を表現できます。

マッピング関数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 入力と出力の型を別々に指定
function transform<TInput, TOutput>(
  input: TInput,
  fn: (value: TInput) => TOutput
): TOutput {
  return fn(input);
}

// 文字列から数値へ変換
const length = transform("TypeScript", (str) => str.length);
console.log(length); // 10(型: number)

// オブジェクトから配列へ変換
interface Person {
  firstName: string;
  lastName: string;
}

const person: Person = { firstName: "John", lastName: "Doe" };
const names = transform(person, (p) => [p.firstName, p.lastName]);
console.log(names); // ["John", "Doe"](型: string[])

キーと値のペア

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// キーと値の型を別々に管理
class TypedMap<K, V> {
  private map = new Map<K, V>();

  set(key: K, value: V): this {
    this.map.set(key, value);
    return this;
  }

  get(key: K): V | undefined {
    return this.map.get(key);
  }

  has(key: K): boolean {
    return this.map.has(key);
  }

  entries(): Array<[K, V]> {
    return Array.from(this.map.entries());
  }
}

// string -> number のマップ
const scores = new TypedMap<string, number>();
scores.set("Alice", 95).set("Bob", 87).set("Charlie", 92);

console.log(scores.get("Alice")); // 95
console.log(scores.entries());    // [["Alice", 95], ["Bob", 87], ["Charlie", 92]]

// symbol -> object のマップ
const metadata = new TypedMap<symbol, { version: string }>();
const KEY = Symbol("app");
metadata.set(KEY, { version: "1.0.0" });

console.log(metadata.get(KEY)); // { version: "1.0.0" }

リクエストとレスポンスの型付け

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// APIエンドポイントの型定義
interface Endpoint<TRequest, TResponse> {
  path: string;
  method: "GET" | "POST" | "PUT" | "DELETE";
  request?: TRequest;
  response?: TResponse;
}

// 型安全なAPIクライアント
async function callApi<TReq, TRes>(
  endpoint: Endpoint<TReq, TRes>,
  data?: TReq
): Promise<TRes> {
  // 実際のAPI呼び出し(簡略化)
  console.log(`Calling ${endpoint.method} ${endpoint.path}`);
  return {} as TRes;
}

// エンドポイント定義
interface CreateUserRequest {
  name: string;
  email: string;
}

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

const createUserEndpoint: Endpoint<CreateUserRequest, User> = {
  path: "/api/users",
  method: "POST"
};

// 使用例
async function main() {
  const newUser = await callApi(createUserEndpoint, {
    name: "Alice",
    email: "alice@example.com"
  });
  
  console.log(newUser.id);   // 型推論が効く
  console.log(newUser.name); // 型推論が効く
}

main();

型パラメータ間の依存関係

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Uの型がTの部分型であることを保証
function merge<T, U extends Partial<T>>(base: T, override: U): T & U {
  return { ...base, ...override };
}

interface Config {
  host: string;
  port: number;
  debug: boolean;
}

const defaultConfig: Config = {
  host: "localhost",
  port: 3000,
  debug: false
};

// 一部だけ上書き
const devConfig = merge(defaultConfig, { debug: true, port: 8080 });
console.log(devConfig);
// { host: "localhost", port: 8080, debug: true }

// 存在しないプロパティを指定するとエラー
// merge(defaultConfig, { invalid: "value" }); // エラー

型パラメータを使ったビルダーパターン

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// ステップごとに型が変わるビルダー
class QueryBuilder<T extends object = {}> {
  private query: T;

  constructor(query: T = {} as T) {
    this.query = query;
  }

  where<K extends string, V>(
    key: K,
    value: V
  ): QueryBuilder<T & Record<K, V>> {
    return new QueryBuilder({
      ...this.query,
      [key]: value
    } as T & Record<K, V>);
  }

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

// 使用例
const query = new QueryBuilder()
  .where("status", "active")
  .where("role", "admin")
  .where("minAge", 18)
  .build();

console.log(query);
// { status: "active", role: "admin", minAge: 18 }
// 型: { status: string } & { role: string } & { minAge: number }

TypeScript条件付き型(Conditional Types)の基礎

条件付き型は、型レベルでの条件分岐を実現する強力な機能です。extendsキーワードを使って型を検査し、結果に応じて異なる型を返します。

条件付き型の基本構文

1
2
3
4
5
6
7
8
// 基本構文: T extends U ? X : Y
// TがUに代入可能ならX、そうでなければY

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

type Test1 = IsString<string>;  // true
type Test2 = IsString<number>;  // false
type Test3 = IsString<"hello">; // true(リテラル型もstringに代入可能)

条件付き型の実践例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 配列の要素型を抽出
type ElementType<T> = T extends (infer U)[] ? U : never;

type StrArray = ElementType<string[]>;     // string
type NumArray = ElementType<number[]>;     // number
type Mixed = ElementType<(string | number)[]>; // string | number
type NotArray = ElementType<string>;       // never

// Promiseの結果型を抽出
type Awaited<T> = T extends Promise<infer U> ? U : T;

type PromiseString = Awaited<Promise<string>>;  // string
type PromiseNumber = Awaited<Promise<number>>;  // number
type NotPromise = Awaited<string>;              // string(そのまま)

関数の型情報を抽出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 関数の引数型を抽出
type Parameters<T> = T extends (...args: infer P) => any ? P : never;

type Fn1Params = Parameters<(x: string, y: number) => void>;
// [x: string, y: number]

type Fn2Params = Parameters<() => void>;
// []

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

type Fn1Return = ReturnType<(x: string) => number>;  // number
type Fn2Return = ReturnType<() => Promise<string>>; // Promise<string>

型に基づく処理の分岐

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 値の型に応じたシリアライズ処理
type Serialize<T> = 
  T extends Date ? string :
  T extends Function ? never :
  T extends object ? { [K in keyof T]: Serialize<T[K]> } :
  T;

interface User {
  id: number;
  name: string;
  createdAt: Date;
  getFullName: () => string;
}

type SerializedUser = Serialize<User>;
// { id: number; name: string; createdAt: string; getFullName: never }

分配条件型(Distributive Conditional Types)

条件付き型にユニオン型を渡すと、各メンバーに対して条件が適用されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ユニオン型の各メンバーに条件を適用
type ToArray<T> = T extends any ? T[] : never;

type StrOrNumArray = ToArray<string | number>;
// string[] | number[]
// ((string | number)[] ではない!)

// 分配を防ぐには[]で囲む
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;

type Combined = ToArrayNonDist<string | number>;
// (string | number)[]

ユニオン型から特定の型を除外

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 組み込みのExcludeの実装
type MyExclude<T, U> = T extends U ? never : T;

type Numbers = 1 | 2 | 3 | 4 | 5;
type OddNumbers = MyExclude<Numbers, 2 | 4>; // 1 | 3 | 5

// オブジェクト型からnullとundefinedを除外
type NonNullable<T> = T extends null | undefined ? never : T;

type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>; // string

条件付き型を使った型ガード

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// オブジェクトのキーを特定の型でフィルタリング
type KeysOfType<T, U> = {
  [K in keyof T]: T[K] extends U ? K : never;
}[keyof T];

interface Mixed {
  id: number;
  name: string;
  email: string;
  age: number;
  isActive: boolean;
}

type StringKeys = KeysOfType<Mixed, string>;  // "name" | "email"
type NumberKeys = KeysOfType<Mixed, number>;  // "id" | "age"
type BooleanKeys = KeysOfType<Mixed, boolean>; // "isActive"

// 特定の型のプロパティのみを抽出
type PickByType<T, U> = Pick<T, KeysOfType<T, U>>;

type StringProps = PickByType<Mixed, string>;
// { name: string; email: string }

TypeScriptジェネリクス実践パターン集

ここまで学んだテクニックを組み合わせた実践的なパターンを紹介します。

型安全なイベントエミッター

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// イベント名と引数の型マッピング
interface EventMap {
  userCreated: { userId: number; name: string };
  userDeleted: { userId: number };
  messageReceived: { from: string; content: string };
}

class TypedEventEmitter<T extends Record<string, any>> {
  private listeners = new Map<keyof T, Set<(data: any) => void>>();

  on<K extends keyof T>(event: K, callback: (data: T[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(callback);
  }

  emit<K extends keyof T>(event: K, data: T[K]): void {
    const callbacks = this.listeners.get(event);
    if (callbacks) {
      callbacks.forEach(cb => cb(data));
    }
  }

  off<K extends keyof T>(event: K, callback: (data: T[K]) => void): void {
    this.listeners.get(event)?.delete(callback);
  }
}

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

emitter.on("userCreated", (data) => {
  // data の型は自動的に { userId: number; name: string }
  console.log(`User ${data.name} created with ID ${data.userId}`);
});

emitter.on("messageReceived", (data) => {
  // data の型は自動的に { from: string; content: string }
  console.log(`Message from ${data.from}: ${data.content}`);
});

emitter.emit("userCreated", { userId: 1, name: "Alice" });
// emitter.emit("userCreated", { userId: 1 }); // エラー: nameが必要

ディープパーシャル型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ネストしたオブジェクトもすべてオプショナルに
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

interface Config {
  server: {
    host: string;
    port: number;
    ssl: {
      enabled: boolean;
      cert: string;
    };
  };
  database: {
    url: string;
    pool: {
      min: number;
      max: number;
    };
  };
}

// すべてのプロパティがオプショナルに
type PartialConfig = DeepPartial<Config>;

const partialConfig: PartialConfig = {
  server: {
    port: 8080,
    ssl: {
      enabled: true
      // certは省略可能
    }
  }
  // databaseは省略可能
};

型安全なフォームバリデーター

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// バリデーションエラーの型
type ValidationErrors<T> = {
  [K in keyof T]?: string[];
};

// バリデーションルールの型
type ValidationRules<T> = {
  [K in keyof T]?: ((value: T[K]) => string | null)[];
};

// バリデーター
function createValidator<T extends object>(
  rules: ValidationRules<T>
) {
  return (data: T): ValidationErrors<T> => {
    const errors: ValidationErrors<T> = {};
    
    for (const key in rules) {
      const fieldRules = rules[key];
      if (!fieldRules) continue;
      
      const fieldErrors: string[] = [];
      for (const rule of fieldRules) {
        const error = rule(data[key]);
        if (error) {
          fieldErrors.push(error);
        }
      }
      
      if (fieldErrors.length > 0) {
        errors[key] = fieldErrors;
      }
    }
    
    return errors;
  };
}

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

const validateUser = createValidator<UserForm>({
  name: [
    (v) => v.length < 2 ? "名前は2文字以上" : null,
    (v) => v.length > 50 ? "名前は50文字以下" : null,
  ],
  email: [
    (v) => !v.includes("@") ? "有効なメールアドレスを入力" : null,
  ],
  age: [
    (v) => v < 0 ? "年齢は0以上" : null,
    (v) => v > 150 ? "年齢は150以下" : null,
  ],
});

const errors = validateUser({
  name: "A",
  email: "invalid-email",
  age: 200
});

console.log(errors);
// { name: ["名前は2文字以上"], email: ["有効なメールアドレスを入力"], age: ["年齢は150以下"] }

TypeScriptジェネリクスの全体像

これまで解説した内容を図にまとめます。

graph TB
    A[TypeScriptジェネリクス実践] --> B[制約の応用]
    A --> C[keyof演算子]
    A --> D[デフォルト型]
    A --> E[複数型パラメータ]
    A --> F[条件付き型]
    
    B --> B1["複数インターフェース制約"]
    B --> B2["コンストラクタ型制約"]
    B --> B3["プリミティブ型制約"]
    
    C --> C1["プロパティアクセス"]
    C --> C2["Pick/Omit"]
    C --> C3["ネストアクセス"]
    
    D --> D1["制約との組み合わせ"]
    D --> D2["複数デフォルト"]
    D --> D3["順序の規則"]
    
    E --> E1["マッピング関数"]
    E --> E2["キー・値ペア"]
    E --> E3["ビルダーパターン"]
    
    F --> F1["infer推論"]
    F --> F2["分配条件型"]
    F --> F3["型フィルタリング"]

よくある間違いと解決策

1. デフォルト型と制約の不一致

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 間違い: デフォルト型が制約を満たさない
interface HasId {
  id: number;
}

// type Container<T extends HasId = string> = { value: T };
// エラー: stringはHasIdを満たさない

// 正解: デフォルト型が制約を満たす
type Container<T extends HasId = { id: number }> = { value: T };

2. 条件付き型での分配を意識しない

1
2
3
4
5
6
7
8
9
// 意図しない分配
type WrapInArray<T> = T extends any ? T[] : never;
type Result = WrapInArray<string | number>;
// string[] | number[] になる(意図が (string | number)[] の場合は問題)

// 分配を防ぐ
type WrapInArrayNonDist<T> = [T] extends [any] ? T[] : never;
type Result2 = WrapInArrayNonDist<string | number>;
// (string | number)[]

3. inferの位置が不適切

1
2
3
4
5
6
// 間違い: inferの位置が不適切
// type GetFirst<T> = T extends infer U[] ? U : never; // 構文エラー

// 正解: inferは適切な位置に
type GetFirst<T> = T extends (infer U)[] ? U : never;
type First = GetFirst<string[]>; // string

まとめ

本記事では、TypeScriptジェネリクスの実践的なテクニックを解説しました。

重要なポイントを振り返ります。

概念 説明 使用例
複合制約 交差型で複数条件を組み合わせ T extends A & B
keyof制約 オブジェクトのキーに制約 K extends keyof T
デフォルト型 型引数省略時のデフォルト T = string
複数型パラメータ 入出力の関係を表現 <TInput, TOutput>
条件付き型 型レベルの条件分岐 T extends U ? X : Y
infer 条件付き型での型推論 T extends (infer U)[] ? U : never

これらのテクニックを組み合わせることで、型安全で再利用可能なコードを設計できます。最初は複雑に感じるかもしれませんが、実際のプロジェクトで少しずつ適用していくことで、自然と使いこなせるようになります。

次回の「TypeScriptユーティリティ型完全ガイド」では、PartialRequiredPickOmitなどの組み込みユーティリティ型について解説します。

参考リンク