型を「計算・変換・生成」する技術

型を書くのではなく、型を作る。それが中級から上級への境界線だ。


この記事で得られること

  • Conditional Types・Mapped Types・Template Literal Typesを自在に使える
  • 共変・反変・不変の仕組みを理解し、型エラーの原因を正確に把握できる
  • 型レベルのプログラミングで、ライブラリ級の型を設計できる

Chapter 1: Conditional Types 完全攻略

1-1. 基本構文

Conditional Typesは「型の三項演算子」です。

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

type A = IsString<string>;  // true
type B = IsString<number>;  // false
type C = IsString<"hello">; // true("hello" は string を extends する)

T extends U ? X : Y という構文で、「TがUに代入可能なら X、そうでなければ Y」を返します。


1-2. infer:型を「分解」して取り出す

infer はConditional Typesの中で使える「型変数の宣言」です。

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

function fetchUser(): Promise<User> { /* ... */ }
type Result = ReturnType<typeof fetchUser>; // Promise<User>

// Promiseを unwrap する
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type Resolved = Awaited<Promise<Promise<string>>>; // string

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

// 関数の第一引数の型を取り出す
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type F = FirstArg<(a: string, b: number) => void>; // string

1-3. 分配的Conditional Types

Union型にConditional Typesを適用すると、各メンバーに分配されます。

type ToArray<T> = T extends any ? T[] : never;

type A = ToArray<string | number>;
// string extends any ? string[] : never
// | number extends any ? number[] : never
// => string[] | number[]

// 分配を防ぎたい場合はタプルでラップする
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
type B = ToArrayNonDist<string | number>; // (string | number)[]

1-4. 実用例:Conditional Typesで型ユーティリティを作る

// null と undefined を再帰的に除去する
type DeepNonNullable<T> = T extends null | undefined
  ? never
  : T extends object
  ? { [K in keyof T]: DeepNonNullable<T[K]> }
  : T;

// オブジェクトの値が特定の型のキーだけを取り出す
type KeysOfType<T, ValueType> = {
  [K in keyof T]: T[K] extends ValueType ? K : never;
}[keyof T];

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

type StringKeys = KeysOfType<User, string>; // "id" | "name"
type NumberKeys = KeysOfType<User, number>; // "age"

1-5. 再帰的Conditional Types(TS 4.1+)

// 深いネストのオブジェクトを全てReadonlyにする
type DeepReadonly<T> = T extends (infer E)[]
  ? ReadonlyArray<DeepReadonly<E>>
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

// ネストされたオブジェクトのパスを型として表現する
type Paths<T, Prefix extends string = ""> = {
  [K in keyof T & string]: T[K] extends object
    ? Paths<T[K], `${Prefix}${K}.`> | `${Prefix}${K}`
    : `${Prefix}${K}`;
}[keyof T & string];

type Config = { db: { host: string; port: number }; timeout: number };
type ConfigPaths = Paths<Config>; // "db" | "db.host" | "db.port" | "timeout"

Chapter 2: Mapped Types と keyof の深掘り

2-1. Mapped Typesの基本

Mapped Typesは「既存の型の全プロパティに変換を適用する」型操作です。

// 基本構文
type Mapped<T> = {
  [K in keyof T]: T[K]; // T の全プロパティをそのままコピー
};

// 組み込みUtility Typesの実装を読み解く
type Readonly<T> = {
  readonly [K in keyof T]: T[K]; // 全プロパティに readonly を付与
};

type Partial<T> = {
  [K in keyof T]?: T[K]; // 全プロパティをオプショナルに
};

type Required<T> = {
  [K in keyof T]-?: T[K]; // オプショナルを除去(- で修飾子を取り除く)
};

2-2. Key Remapping(TS 4.1+)

as を使ってキー名を変換できます。

// 全プロパティ名に "get" プレフィックスを付けた Getter を生成
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface User { name: string; age: number; }
type UserGetters = Getters<User>;
// => { getName: () => string; getAge: () => number }

// 特定のプロパティを除外する
type OmitPrivate<T> = {
  [K in keyof T as K extends `_${string}` ? never : K]: T[K];
};

interface Config {
  host: string;
  _password: string; // プライベートプロパティ(慣習)
  port: number;
}
type PublicConfig = OmitPrivate<Config>;
// => { host: string; port: number }

2-3. keyof の完全理解

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

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

// インデックス型のkeyof
type Dict = { [key: string]: number };
type DictKeys = keyof Dict; // string | number
// ※ JSでは obj[0] と obj["0"] は同じキーを指すため number も含まれる

// typeof と組み合わせる
const config = { host: "localhost", port: 3000 } as const;
type ConfigKeys = keyof typeof config; // "host" | "port"
type ConfigValues = typeof config[keyof typeof config]; // "localhost" | 3000

2-4. Mapped Types の実践例

// イベントハンドラの型を自動生成
type EventHandlers<T extends string> = {
  [K in T as `on${Capitalize<K>}`]?: (event: Event) => void;
};

type ClickEvents = EventHandlers<"click" | "dblclick" | "contextmenu">;
// => { onClick?: ...; onDblclick?: ...; onContextmenu?: ... }

// バリデーションルールを型から生成
type ValidationRules<T> = {
  [K in keyof T]?: {
    required?: boolean;
    minLength?: T[K] extends string ? number : never;
    min?: T[K] extends number ? number : never;
    max?: T[K] extends number ? number : never;
  };
};

type UserValidation = ValidationRules<User>;
// name の minLength は有効、age の min/max は有効
// name の min は never(型が合わない)

Chapter 3: Template Literal Types 実践

3-1. 基本構文

type Greeting = `Hello, ${string}!`; // "Hello, xxx!" の形を持つ全ての string

// Union型と組み合わせると爆発的に展開される
type Color = "red" | "green" | "blue";
type Size  = "sm" | "md" | "lg";
type ClassName = `${Color}-${Size}`;
// => "red-sm" | "red-md" | "red-lg" | "green-sm" | ... (9種類)

3-2. 組み込みの文字列操作型

type S = "hello world";
type U = Uppercase<S>;   // "HELLO WORLD"
type L = Lowercase<S>;   // "hello world"
type C = Capitalize<S>;  // "Hello world"
type U2 = Uncapitalize<"HelloWorld">; // "helloWorld"

3-3. 実践:型安全なイベントシステム

type EventMap = {
  click: { x: number; y: number };
  keydown: { key: string; code: string };
  resize: { width: number; height: number };
};

type EventListener<T extends keyof EventMap> =
  (event: EventMap[T]) => void;

// on<EventName> の形でハンドラを定義する
type EventHandlerMap = {
  [K in keyof EventMap as `on${Capitalize<K>}`]: EventListener<K>;
};
// => { onClick: (e: { x: number; y: number }) => void; ... }

3-4. 実践:APIルートの型安全化

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type ApiVersion = "v1" | "v2";
type Resource = "users" | "posts" | "comments";
type ResourceId = `${Resource}/:id`;

type ApiRoute =
  | `/${ApiVersion}/${Resource}`
  | `/${ApiVersion}/${ResourceId}`;

// リクエスト関数の型付け
declare function apiRequest(
  method: HttpMethod,
  route: ApiRoute
): Promise<unknown>;

apiRequest("GET", "/v1/users");        // ✅
apiRequest("GET", "/v1/users/:id");    // ✅
apiRequest("GET", "/v3/users");        // ❌ コンパイルエラー
apiRequest("CONNECT", "/v1/users");    // ❌ コンパイルエラー

3-5. 実践:CSSクラス名の型安全化

type Breakpoint = "sm" | "md" | "lg" | "xl";
type Property = "p" | "m" | "px" | "py" | "mx" | "my";
type Scale = "0" | "1" | "2" | "4" | "8" | "16";

type SpacingClass = `${Property}-${Scale}`;
type ResponsiveClass = `${Breakpoint}:${SpacingClass}`;

type TailwindLike = SpacingClass | ResponsiveClass;

function cn(...classes: TailwindLike[]): string {
  return classes.join(" ");
}

cn("p-4", "md:px-8"); // ✅
cn("p-3");             // ❌ Scale に "3" はない

Chapter 4: 共変・反変・不変を理解する

TypeScriptで最も難解なトピックの1つです。型エラーのメッセージが理解できない原因の多くはここにあります。

4-1. 変性(Variance)とは何か

「変性」は「複合型(配列・関数など)の互換性が、その要素型の互換性とどう対応するか」を表す概念です。

まず基本となる型の継承関係を定義します:

class Animal { breathe() {} }
class Dog extends Animal { bark() {} }
class Poodle extends Dog { dance() {} }

// Poodle は Dog のサブタイプ
// Dog は Animal のサブタイプ

4-2. 共変(Covariance):サブタイプ関係が「保たれる」

配列・オブジェクトのプロパティ(読み取り専用)は共変です。

// Dog は Animal のサブタイプ
// → Dog[] は Animal[] のサブタイプ(共変)

const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // ✅ 共変なので OK

// 読み取り専用プロパティも共変
interface Box<T> { readonly value: T }
const dogBox: Box<Dog> = { value: new Dog() };
const animalBox: Box<Animal> = dogBox; // ✅ OK

4-3. 反変(Contravariance):サブタイプ関係が「逆転する」

関数の引数は反変です。

type Handler<T> = (val: T) => void;

// Dog は Animal のサブタイプ
// しかし Handler<Dog> と Handler<Animal> の関係は「逆」

const handleAnimal: Handler<Animal> = (a) => a.breathe();
const handleDog: Handler<Dog> = handleAnimal; // ✅ OK
// なぜ? handleAnimal は任意の Animal を受け取れる = Dog も受け取れる

const handleDog2: Handler<Dog> = (d) => d.bark();
const handleAnimal2: Handler<Animal> = handleDog2; // ❌ エラー
// なぜ? handleDog2 は Dog.bark() を呼ぶが、Animal には bark がない

直感的に言うと:「より広い型を受け取れる関数は、より狭い型を受け取れる関数の代わりになれる」


4-4. 不変(Invariance):どちらの関係も成立しない

読み書き両方可能な型パラメータは不変になります。

// 書き込み可能な配列は不変
const dogs: Dog[] = [new Dog()];
// const animals: Animal[] = dogs; // 実はこれは安全でない!

// なぜか?
animals.push(new Animal()); // Animal を追加
dogs[0].bark(); // dogs は Animal になっているのに bark を呼ぶ → 危険

// TypeScriptは配列を共変として扱う(利便性のための妥協)
// より厳密にしたい場合は ReadonlyArray を使う

4-5. strictFunctionTypes と関数の変性

strictFunctionTypes: truestrict: true に含まれる)を有効にすると、関数の引数が正しく反変チェックされます。

// strictFunctionTypes が有効な場合

type Callback<T> = (val: T) => void;

declare let cbDog: Callback<Dog>;
declare let cbAnimal: Callback<Animal>;

cbDog = cbAnimal; // ✅ OK(反変:より広い型を受け取れる方向)
cbAnimal = cbDog; // ❌ エラー(反変:逆方向は不可)

4-6. 変性の実務的影響

// ジェネリクスの変性を意識した設計

// Producer(生産者):T を返すだけ → 共変として安全
interface Producer<out T> {  // TS 4.7+ の変性アノテーション
  produce(): T;
}

// Consumer(消費者):T を受け取るだけ → 反変として安全
interface Consumer<in T> {
  consume(val: T): void;
}

// Transformer(変換者):T を受け取り T を返す → 不変
interface Transformer<T> {
  transform(val: T): T;
}

まとめ:Vol.2 のチェックリスト

  • [ ] infer を使って型を分解・取り出せる
  • [ ] 分配的Conditional Typesと非分配の違いを説明できる
  • [ ] Key Remapping で型のキー名を変換できる
  • [ ] keyofstring | number を返す場合があることを知っている
  • [ ] Template Literal Types でAPIルートを型安全に定義できる
  • [ ] 共変・反変・不変の違いを具体的なコードで説明できる
  • [ ] strictFunctionTypes が何をチェックするか理解している

次回 Vol.3「実践設計パターン」 では、Branded Types・エラーハンドリング・zod・DI・State Machineを解説します。


質問・フィードバックはコメントにてどうぞ。