コンテンツへスキップ
·11分で読めます

TypeScriptの高度な型:条件型、マップ型、テンプレートリテラル型の魔法

TypeScriptの最も強力な型機能を徹底解説。条件型、マップ型、inferキーワード、テンプレートリテラル型など、実際のプロダクションで使えるパターンを紹介します。

シェア:X / TwitterLinkedIn

TypeScriptの開発者の多くは、同じところで成長が止まります。インターフェース、ジェネリクス、ユニオン型は理解している。Reactコンポーネントの型付けもできる。赤い波線を消すこともできる。しかし、型システムを必要悪として扱っている。バグを防ぐためだけに存在するものであり、より良いソフトウェア設計を積極的に支援するものとは考えていないのです。

私もそのプラトーに約2年間いました。そこから抜け出せたのは、TypeScriptの型システムがそれ自体でひとつのプログラミング言語であると気づいたときです。条件分岐、ループ、パターンマッチング、再帰、文字列操作がある。それを内面化すると、すべてが変わります。コンパイラと戦うのではなく、コンパイラと協力するようになるのです。

この記事では、私がプロダクションコードで日常的に使っている型レベルの機能を紹介します。学術的な演習ではなく、実際のバグから私を救ってくれた実用的なパターンです。

型レベルプログラミングのマインドセット#

構文に入る前に、型に対する考え方を再構成しましょう。

値レベルのプログラミングでは、ランタイムにデータを変換する関数を書きます:

typescript
function extractIds(users: User[]): number[] {
  return users.map(u => u.id);
}

型レベルのプログラミングでは、コンパイル時に他の型を変換する型を書きます:

typescript
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];

メンタルモデルは同じです。入力、変換、出力。違いは、型レベルのコードはコンパイル時に実行され、ランタイムには実行されないということです。JavaScriptは一切生成されません。その唯一の目的は、不正な状態を表現不可能にすることです。

4つのパラメータのうち3つが特定の組み合わせでしか意味をなさない関数を書いたことがあるなら、あるいは戻り値の型が渡す値に依存する関数を書いたことがあるなら、すでに型レベルプログラミングが必要だったのです。TypeScriptでそれを表現できることを知らなかっただけかもしれません。

条件型#

条件型は、型システムにおけるif/elseです。構文は三項演算子に似ています:

typescript
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;      // false
type C = IsString<string>;  // true

ここでのextendsキーワードは継承を意味しません。「代入可能かどうか」を意味します。「Tはstringの形に当てはまるか?」と考えてください。

独自のユーティリティ型を構築する#

TypeScriptの組み込みユーティリティ型を再構築して、その仕組みを理解しましょう。

NonNullable — ユニオンからnullundefinedを除去します:

typescript
type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type Test = MyNonNullable<string | null | undefined>;
// Result: string

never型は「空集合」です。ユニオンから何かを取り除きます。条件型の分岐がneverに解決されると、そのユニオンのメンバーは単純に消えます。

Extract — 制約に一致するユニオンメンバーだけを残します:

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | boolean

Exclude — 逆に、一致するメンバーを除去します:

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | boolean

分配的条件型#

ここから面白くなります。そして多くの人が混乱するところでもあります。ユニオン型を条件型に渡すと、TypeScriptはユニオンの各メンバーに対して個別に条件を分配します。

typescript
type ToArray<T> = T extends unknown ? T[] : never;
 
type Result = ToArray<string | number>;
// You might expect: (string | number)[]
// Actual result:    string[] | number[]

TypeScriptは(string | number) extends unknownを評価するのではなく、string extends unknownnumber extends unknownを個別に評価し、結果をユニオンにします。

これがExtractExcludeがそのように動作する理由です。分配は、Tが「裸の」型パラメータ(何にもラップされていない)のときに自動的に行われます。

分配を防止したい場合は、両側をタプルでラップします:

typescript
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
 
type Result = ToArrayNonDist<string | number>;
// Result: (string | number)[]

これには数えきれないほどハマりました。条件型がユニオン入力で予期しない結果を返している場合、ほぼ確実に分配が原因です。

実践例:APIレスポンスハンドラー#

成功または失敗する可能性のあるAPIレスポンスを扱うときに使うパターンです:

typescript
type ApiResponse<T> =
  | { status: "success"; data: T; error: never }
  | { status: "error"; data: never; error: string };
 
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
 
type UserResponse = ApiResponse<{ id: number; name: string }>;
 
type UserData = ExtractData<UserResponse>;
// Result: { id: number; name: string }
 
type UserError = ExtractError<UserResponse>;
// Result: string

これは、すべてのレスポンスの形に対して手動で型ガードを書くよりもクリーンです。

inferキーワード#

inferは型のパターンマッチングです。条件型の中で型変数を宣言し、TypeScriptにそれを推論させることができます。

次のように考えてください:「この型が特定の形をしていることは分かっている。関心のある部分を取り出してくれ。」

戻り値の型を抽出する#

typescript
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
 
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // boolean

TypeScriptは(...args) => somethingというパターンを見て、あなたの関数をそれにマッチさせ、戻り値の型をRに代入します。

パラメータの型を抽出する#

typescript
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
 
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]

Promiseのアンラップ#

非同期関数を扱うときにいつも使うパターンです:

typescript
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
 
type A = UnpackPromise<Promise<string>>;  // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>;            // string (passthrough)

深くネストされたPromiseの場合:

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: number

配列の要素型を抽出する#

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type Items = ElementOf<string[]>;         // string
type Mixed = ElementOf<(string | number)[]>; // string | number

ひとつの型で複数のinfer#

inferを複数回使って異なる部分を抽出できます:

typescript
type FirstAndLast<T extends unknown[]> =
  T extends [infer First, ...unknown[], infer Last]
    ? { first: First; last: Last }
    : never;
 
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Result: { first: 1; last: 4 }

実践例:型安全なイベント抽出#

イベント駆動システムで使っているパターンです:

typescript
type EventMap = {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "order:created": { orderId: string; total: number };
  "order:shipped": { orderId: string; trackingId: string };
};
 
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
 
// Extract all user-related events
type UserEvents = Extract<EventName, `user:${string}`>;
// Result: "user:login" | "user:logout"
 
// Get the payload for user events only
type UserEventPayloads = EventPayload<UserEvents>;
// Result: { userId: string; timestamp: number } | { userId: string }

マップ型#

マップ型は、既存の型の各プロパティを変換して新しいオブジェクト型を作成します。型システムにおけるmap()です。

基本#

typescript
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};
 
type Partial<T> = {
  [K in keyof T]?: T[K];
};

keyof Tはすべてのプロパティキーのユニオンを返します。[K in ...]は各キーを反復処理します。T[K]はそのプロパティの型です。

ユーティリティ型をゼロから構築する#

Required — すべてのプロパティを必須にする:

typescript
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

-?構文はオプショナル修飾子を除去します。同様に、-readonlyはreadonlyを除去します。

Pick — 特定のプロパティを選択する:

typescript
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};
 
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}
 
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }

Omit — 特定のプロパティを除去する:

typescript
type MyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P];
};
 
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }

Record — 特定のキーと値の型を持つオブジェクト型を作成する:

typescript
type MyRecord<K extends string | number | symbol, V> = {
  [P in K]: V;
};
 
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }

asによるキーのリマッピング#

TypeScript 4.1で導入されたキーリマッピングにより、プロパティ名を変換できます:

typescript
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
 
interface Person {
  name: string;
  age: number;
  email: string;
}
 
type PersonGetters = Getters<Person>;
// {
//   getName: () => string;
//   getAge: () => number;
//   getEmail: () => string;
// }

neverにリマッピングすることでプロパティをフィルタリングすることもできます:

typescript
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
 
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }

これは非常に強力です。プロパティの反復処理、フィルタリング、名前の変換を同時に、すべて型レベルで行っています。

実践例:フォームバリデーション型#

複数のフォームライブラリで使ったパターンです:

typescript
interface FormFields {
  username: string;
  email: string;
  age: number;
  bio: string;
}
 
type ValidationErrors<T> = {
  [K in keyof T]?: string[];
};
 
type TouchedFields<T> = {
  [K in keyof T]?: boolean;
};
 
type DirtyFields<T> = {
  [K in keyof T]?: boolean;
};
 
type FormState<T> = {
  values: T;
  errors: ValidationErrors<T>;
  touched: TouchedFields<T>;
  dirty: DirtyFields<T>;
  isValid: boolean;
  isSubmitting: boolean;
};
 
// Now your form state is fully typed based on the fields
type MyFormState = FormState<FormFields>;

FormFieldsにフィールドを追加すれば、関連するすべての型が自動的に更新されます。フィールドを削除すれば、コンパイラがすべての参照をキャッチします。これがバグのカテゴリ全体を防ぐ仕組みです。

テンプレートリテラル型#

テンプレートリテラル型は、型レベルで文字列を操作できます。JavaScriptのテンプレートリテラルと同じバッククォート構文を使いますが、型として使います。

基本的な文字列操作#

typescript
type Greeting = `Hello, ${string}`;
 
const a: Greeting = "Hello, world";    // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world";    // Error!

TypeScriptは組み込みの文字列操作型も提供しています:

typescript
type Upper = Uppercase<"hello">;     // "HELLO"
type Lower = Lowercase<"HELLO">;     // "hello"
type Cap = Capitalize<"hello">;      // "Hello"
type Uncap = Uncapitalize<"Hello">;  // "hello"

イベント名パターン#

テンプレートリテラル型が真価を発揮する場面です。型安全なイベントシステムで使うパターンを紹介します:

typescript
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
 
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"

2つの型パラメータから9つのイベント。しかもすべて型安全です。

CSS-in-TypeScriptパターン#

テンプレートリテラル型は、型レベルでCSSパターンを強制できます:

typescript
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
 
function setWidth(value: CSSValue): void {
  // implementation
}
 
setWidth("100px");   // OK
setWidth("2.5rem");  // OK
setWidth("50%");     // OK
// setWidth("100");  // Error: not a valid CSS value
// setWidth("big");  // Error

テンプレートリテラルによる文字列パース#

テンプレートリテラル型とinferを組み合わせて文字列をパースできます:

typescript
type ParseRoute<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? Param | ParseRoute<`/${Rest}`>
    : T extends `${infer _Start}:${infer Param}`
      ? Param
      : never;
 
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Result: "userId" | "postId"

これがtRPCのような型安全なルーティングライブラリの基盤です。文字列/users/:userId/posts/:postIdが型レベルでパースされ、パラメータ名が抽出されます。

テンプレートリテラルとマップ型の組み合わせ#

ここからが本当に強力になります:

typescript
type EventHandlers<T extends Record<string, unknown>> = {
  [K in keyof T as `on${Capitalize<string & K>}Change`]: (
    newValue: T[K],
    oldValue: T[K]
  ) => void;
};
 
interface ThemeConfig {
  color: string;
  fontSize: number;
  darkMode: boolean;
}
 
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
//   onColorChange: (newValue: string, oldValue: string) => void;
//   onFontSizeChange: (newValue: number, oldValue: number) => void;
//   onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }

シンプルなconfigインターフェースから、正しいパラメータ型を持つ完全に型付けされたイベントハンドラーが得られます。これこそ、実際に開発時間を節約する型レベルコードです。

高度な例:ドット記法パス型#

さらに高度なパターンとして、ネストされたオブジェクトを通るすべてのドット記法パスを生成する例を紹介します:

typescript
type DotPaths<T, Prefix extends string = ""> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
        : `${Prefix}${K}`;
    }[keyof T & string]
  : never;
 
interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  server: {
    port: number;
  };
}
 
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"

これにより、すべてのパスを自動補完し、各パスに対して正しい型を返すgetConfig関数を書くことができます:

typescript
type GetValueByPath<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? GetValueByPath<T[Key], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never;
 
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
  // implementation
  const keys = path.split(".");
  let result: unknown = config;
  for (const key of keys) {
    result = (result as Record<string, unknown>)[key];
  }
  return result as GetValueByPath<Config, P>;
}
 
const host = getConfig("database.host");         // type: string
const port = getConfig("database.port");         // type: number
const username = getConfig("database.credentials.username"); // type: string

完全な自動補完、完全な型安全性、型によるランタイムオーバーヘッドはゼロです。

再帰型#

TypeScriptは再帰的な型定義をサポートしており、任意の深さのネスト構造を扱えます。

DeepPartial#

組み込みのPartial<T>はトップレベルのプロパティのみをオプショナルにします。ネストされたオブジェクトには再帰が必要です:

typescript
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object
    ? T[K] extends Array<infer U>
      ? Array<DeepPartial<U>>
      : DeepPartial<T[K]>
    : T[K];
};
 
interface Settings {
  theme: {
    colors: {
      primary: string;
      secondary: string;
    };
    fonts: {
      heading: string;
      body: string;
    };
  };
  notifications: {
    email: boolean;
    push: boolean;
  };
}
 
type PartialSettings = DeepPartial<Settings>;
// Every nested property is now optional

配列のチェックに注意してください。これがないと、配列がオブジェクトとして扱われ、数値インデックスがオプショナルになってしまいます。それは望む動作ではありません。

DeepReadonly#

同じパターンで、異なる修飾子を使います:

typescript
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : T[K] extends Array<infer U>
        ? ReadonlyArray<DeepReadonly<U>>
        : DeepReadonly<T[K]>
    : T[K];
};

Functionチェックは重要です。これがないと、関数プロパティの内部構造にreadonlyが適用されてしまいますが、それは意味をなしません。

JSON型#

再帰型の古典的な例として、有効なJSON値を表現する型があります:

typescript
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
 
function parseJson(input: string): JsonValue {
  return JSON.parse(input);
}
 
function isJsonObject(value: JsonValue): value is JsonObject {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

これはJSONパースにanyを使うよりもはるかに良い方法です。「何かが返ってきますが、使う前にナローイングが必要です」とコンシューマーに伝えます。それはまさに真実そのものです。

ネストメニューの再帰型#

実用的な例として、ナビゲーションメニュー構造の型付けがあります:

typescript
interface MenuItem {
  label: string;
  href: string;
  icon?: string;
  children?: MenuItem[];
}
 
type Menu = MenuItem[];
 
const navigation: Menu = [
  {
    label: "Products",
    href: "/products",
    children: [
      {
        label: "Software",
        href: "/products/software",
        children: [
          { label: "IDE", href: "/products/software/ide" },
          { label: "CLI Tools", href: "/products/software/cli" },
        ],
      },
      { label: "Hardware", href: "/products/hardware" },
    ],
  },
  { label: "About", href: "/about" },
];

再帰的なchildrenプロパティにより、完全な型安全性を持つ無限のネストが可能になります。

実践パターン#

プロダクションシステムで実際に使ったパターンをいくつか紹介します。

ビルダーパターンと型#

型システムが何が設定されたかを追跡するとき、ビルダーパターンは格段に便利になります:

typescript
type RequiredKeys = "host" | "port" | "database";
 
type BuilderState = {
  [K in RequiredKeys]?: true;
};
 
class DatabaseConfigBuilder<State extends BuilderState = {}> {
  private config: Partial<{
    host: string;
    port: number;
    database: string;
    ssl: boolean;
    poolSize: number;
  }> = {};
 
  setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
    this.config.host = host;
    return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
  }
 
  setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
    this.config.port = port;
    return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
  }
 
  setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
    this.config.database = db;
    return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
  }
 
  setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
    this.config.ssl = ssl;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  setPoolSize(size: number): DatabaseConfigBuilder<State> {
    this.config.poolSize = size;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  build(
    this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
  ): Required<Pick<typeof this.config, "host" | "port" | "database">> &
    Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
    return this.config as ReturnType<typeof this.build>;
  }
}
 
// This compiles:
const config = new DatabaseConfigBuilder()
  .setHost("localhost")
  .setPort(5432)
  .setDatabase("myapp")
  .setSsl(true)
  .build();
 
// This fails at compile time — missing .setDatabase():
// const bad = new DatabaseConfigBuilder()
//   .setHost("localhost")
//   .setPort(5432)
//   .build(); // Error: 'database' is missing

build()メソッドは、すべての必須フィールドが設定されたときにのみ呼び出し可能になります。これはランタイムではなくコンパイル時にキャッチされます。

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

ペイロードの型が強制されるイベントエミッターです:

typescript
type EventMap = Record<string, unknown>;
 
class TypedEventEmitter<Events extends EventMap> {
  private listeners: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>;
  } = {};
 
  on<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }
 
  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners[event]?.forEach((listener) => listener(payload));
  }
 
  off<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    const handlers = this.listeners[event];
    if (handlers) {
      this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
    }
  }
}
 
// Usage
interface AppEvents {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "notification:new": { message: string; level: "info" | "warning" | "error" };
}
 
const emitter = new TypedEventEmitter<AppEvents>();
 
// Fully typed — IDE autocompletes event names and payload shapes
emitter.on("user:login", (payload) => {
  console.log(payload.userId);     // string
  console.log(payload.timestamp);  // number
});
 
// Type error: payload doesn't match
// emitter.emit("user:login", { userId: "123" });
// Error: missing 'timestamp'
 
// Type error: event doesn't exist
// emitter.on("user:signup", () => {});
// Error: "user:signup" is not in AppEvents

このパターンは3つの異なるプロダクションプロジェクトで使いました。イベント関連のバグのカテゴリ全体をコンパイル時にキャッチします。

判別ユニオンと網羅性チェック#

判別ユニオンは、おそらくTypeScriptで最も有用な単一パターンです。網羅性チェックと組み合わせると、すべてのケースを処理することが保証されます:

typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      // This line ensures exhaustive checking
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

誰かが新しいシェイプのバリアント(例えば"pentagon")を追加した場合、neverに値を代入できないため、この関数はコンパイルに失敗します。コンパイラがすべてのケースを処理するよう強制するのです。

さらにヘルパー関数で発展させます:

typescript
function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
  }
}

型安全なステートマシン#

判別ユニオンはステートマシンも美しくモデル化します:

typescript
type ConnectionState =
  | { status: "disconnected" }
  | { status: "connecting"; attempt: number }
  | { status: "connected"; socket: WebSocket; connectedAt: Date }
  | { status: "error"; error: Error; lastAttempt: Date };
 
type ConnectionAction =
  | { type: "CONNECT" }
  | { type: "CONNECTED"; socket: WebSocket }
  | { type: "DISCONNECT" }
  | { type: "ERROR"; error: Error }
  | { type: "RETRY" };
 
function connectionReducer(
  state: ConnectionState,
  action: ConnectionAction
): ConnectionState {
  switch (state.status) {
    case "disconnected":
      if (action.type === "CONNECT") {
        return { status: "connecting", attempt: 1 };
      }
      return state;
 
    case "connecting":
      if (action.type === "CONNECTED") {
        return {
          status: "connected",
          socket: action.socket,
          connectedAt: new Date(),
        };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "connected":
      if (action.type === "DISCONNECT") {
        state.socket.close();
        return { status: "disconnected" };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "error":
      if (action.type === "RETRY") {
        return { status: "connecting", attempt: 1 };
      }
      if (action.type === "DISCONNECT") {
        return { status: "disconnected" };
      }
      return state;
 
    default:
      assertNever(state, "Unknown connection state");
  }
}

各状態は、その状態で意味のあるプロパティだけを持ちます。切断中にsocketにアクセスすることはできません。接続中にerrorにアクセスすることもできません。型システムがステートマシンの制約を強制します。

ブランド型によるドメイン安全性#

もうひとつ不可欠だと感じるパターンとして、たまたま同じ基底型を共有する値の混同を防ぐブランド型があります:

typescript
type Brand<T, B extends string> = T & { readonly __brand: B };
 
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
 
function createUserId(id: string): UserId {
  return id as UserId;
}
 
function createOrderId(id: string): OrderId {
  return id as OrderId;
}
 
function getUser(id: UserId): Promise<User> {
  // ...
}
 
function getOrder(id: OrderId): Promise<Order> {
  // ...
}
 
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
 
getUser(userId);   // OK
// getUser(orderId); // Error! OrderId is not assignable to UserId

UserIdOrderIdはどちらもランタイムでは文字列です。しかしコンパイル時には異なる型です。注文IDをユーザーIDが期待される場所に渡すことは文字通り不可能です。このパターンは使ったすべてのプロジェクトで実際のバグをキャッチしました。

よくある落とし穴#

高度な型は強力ですが、罠もあります。私が身をもって学んだことを共有します。

循環参照の制限#

TypeScriptには型の再帰深度制限があります(現在は約50レベルですが変動します)。深くなりすぎると、恐ろしい「Type instantiation is excessively deep and possibly infinite」エラーが表示されます。

typescript
// This will hit the recursion limit for deeply nested objects
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
 
// Fix: Add a depth counter
type DeepPartialSafe<T, Depth extends number[] = []> =
  Depth["length"] extends 10
    ? T
    : {
        [K in keyof T]?: T[K] extends object
          ? DeepPartialSafe<T[K], [...Depth, 0]>
          : T[K];
      };

深度カウンターのトリックは、再帰ステップごとに成長するタプルを使います。制限に達すると再帰が停止します。

パフォーマンスへの影響#

複雑な型はエディタのパフォーマンスとビルド時間を大幅に低下させる可能性があります。過度に凝った単一の型がキーストロークごとのフィードバックループに3秒追加したプロジェクトを見たことがあります。

警告サイン:

  • IDEが自動補完を表示するのに2秒以上かかる
  • 型を追加した後にtsc --noEmitが明らかに遅くなる
  • 「Type instantiation is excessively deep」エラーが表示される
typescript
// This is too clever — it generates a combinatorial explosion
type AllCombinations<T extends string[]> = T extends [
  infer First extends string,
  ...infer Rest extends string[]
]
  ? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
  : "";
 
// Don't do this with more than 5-6 elements

高度な型を使うべきでないとき#

これがおそらく最も重要なセクションです。高度な型は、バグを防ぐ開発者体験を向上させるときに使うべきです。見せびらかすために使うべきではありません。

使うべきでないとき:

  • シンプルなRecord<string, unknown>で十分な場合
  • 型が1か所でしか使われず、具体的な型の方が明確な場合
  • 型のデバッグに費やす時間が、それが節約する時間より長い場合
  • チームがそれを読んだり保守したりできない場合
  • ランタイムチェックの方が適切な場合

使うべきとき:

  • 多くの型で繰り返されるパターンがある場合(マップ型)
  • 関数の戻り値の型が入力に依存する場合(条件型)
  • 自己文書化が必要なライブラリAPIを構築している場合
  • 不正な状態を表現不可能にしたい場合
  • コードレビューで同じカテゴリのバグが繰り返し出現するのに疲れた場合
typescript
// Over-engineered — don't do this for a simple config
type Config = DeepReadonly<
  DeepRequired<
    Merge<DefaultConfig, Partial<UserConfig>>
  >
>;
 
// Just do this
interface Config {
  readonly host: string;
  readonly port: number;
  readonly debug: boolean;
}

私の経験則:型を説明するのに、それが防ぐバグを説明するより長くかかるなら、シンプルにすべきです。

複雑な型のデバッグ#

複雑な型がうまく動かないとき、TypeScriptが何に解決したかを「見る」ためにこのヘルパーを使います:

typescript
// Expands a type for inspection in IDE hover tooltips
type Prettify<T> = {
  [K in keyof T]: T[K];
} & {};
 
// Use it to debug
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Now hover over Debug in your IDE to see the resolved type

& {}のトリックは、TypeScriptに型エイリアスを表示する代わりに型を積極的に評価させます。何時間もの混乱を救ってくれました。

もうひとつのテクニック — 分離してインクリメンタルにテストする:

typescript
// Instead of debugging this all at once:
type Final = StepThree<StepTwo<StepOne<Input>>>;
 
// Break it apart:
type AfterStep1 = StepOne<Input>;       // hover to check
type AfterStep2 = StepTwo<AfterStep1>;  // hover to check
type AfterStep3 = StepThree<AfterStep2>; // hover to check

まとめ#

  • 条件型T extends U ? X : Y)は型のif/elseです。ユニオンでの分配的な動作に注意してください。
  • **infer**はパターンマッチングです。関数シグネチャ、Promise、配列などの構造から型を抽出するために使います。
  • マップ型{ [K in keyof T]: ... })はプロパティを反復処理します。asと組み合わせてキーのリマッピングやフィルタリングが可能です。
  • テンプレートリテラル型は型レベルで文字列を操作します。マップ型と組み合わせると、API設計において非常に強力です。
  • 再帰型はネスト構造を扱いますが、コンパイラの爆発を避けるために深度制限が必要です。
  • 判別ユニオンと網羅性チェックは、TypeScriptで最も価値の高い単一パターンです。あらゆる場所で使いましょう。
  • ブランド型は、同じ基底型を共有する値の混同を防ぎます。実装はシンプル、効果は絶大です。
  • **型を過度に設計しないこと。**型が防ぐバグよりも理解しにくいなら、シンプルにしましょう。目標はコードベースをより安全にすることであり、型ゴルフのコンペで優勝することではありません。

TypeScriptの型システムはチューリング完全です。つまり、ほぼ何でもできます。その技術は、いつすべきかを知ることにあります。

関連記事