Mapped types と TypeScript の条件付き型

1. 概要

TypeScript プロジェクトでは、似たような型を多く書く必要があります:Order、その次に ReadonlyOrder、OrderUpdate、OrderGetters などです。手動で各型を書くのは時間がかかり、Order が変わるときに同期がずれやすいです。

Mapped types と条件付き型がこの問題を解決します。これらは既存の型から新しい型を生成することができます。この記事を読み終わると、Partial、PickByType、UnwrapPromise のようなユーティリティ型を自分で書けるようになります。また、これらをテストする方法と、いつやめるべきかもわかります。

2. 準備

TypeScript 5.x と strict を有効にしてください。この記事では型テストに Vitest を使用します:

npm install -D typescript vitest
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true
  }
}

strict: true は strictNullChecks も有効にします。この記事の一部の結果、例えば undefined が string と一致するかどうかは、このフラグに依存します。

例は次の共通の型を使用します:

export interface Order {
  id: string;
  customerId: string;
  total: number;
  paid: boolean;
  note?: string;
}

3. Mapped types

3.1. 基本文法

Mapped type は、キーのセットをループして、各キーに対してプロパティを作成します:

type ReadonlyOrder = {
  readonly [K in keyof Order]: Order[K];
};

const order: ReadonlyOrder = {
  id: "o-1",
  customerId: "c-1",
  total: 250_000,
  paid: false,
};

order.paid = true; // コンパイルエラー

説明:

  • keyof Order は union の "id" | "customerId" | "total" | "paid" | "note" です。
  • [K in ...] は、この union の各キーをループします。
  • Order[K] はインデックスアクセス型です。これはプロパティ K の型を取得します。
  • [K in keyof T] 形式の Mapped type はホモモルフィックと呼ばれます。この形式は元の修飾子を保持するため、note はまだオプションのままです。

最後の割り当ての行がエラーを報告します:

error TS2540: Cannot assign to 'paid' because it is a read-only property.

上記の例はまだ Order にハードコーディングされています。すべての型で再利用可能にするには、generic パラメータを追加します:

type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyPartial<T> = { [K in keyof T]?: T[K] };

type OrderUpdate = MyPartial<Order>;
// { id?: string; customerId?: string; total?: number; paid?: boolean; note?: string }

これは、標準ライブラリが Readonly<T> と Partial<T> をどのように定義しているかです。

3.2. 修飾子の追加と削除

readonly と ? の前に + または - を配置できます。+ はデフォルトであり、通常は省略されます。- は修飾子を削除するために使用されます:

type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };

type CompleteOrder = Concrete<Order>;

const draft: CompleteOrder = {
  id: "o-2",
  customerId: "c-9",
  total: 99_000,
  paid: true,
  note: "営業時間での配送",
};

Concrete<Order> では、プロパティ note が必須になります。その型は string で、もはや string | undefined ではありません。標準ライブラリには Required<T> があり、これはまさにこれを行います。

3.3. as によるキーの名前変更

TypeScript 4.1 以降では、as 句を使用してキーの名前を変更できます。このアプローチはしばしば template literal type と共に使用されます:

interface Customer {
  id: string;
  name: string;
  email: string;
}

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type CustomerGetters = Getters<Customer>;
// {
//   getId: () => string;
//   getName: () => string;
//   getEmail: () => string;
// }

string & K の部分は必須です。keyof T は number と symbol も含む可能性があり、Capitalize は string のみを受け入れます。型の交差は非文字列のキーを削除します。

また、as を使用してキーをフィルタリングすることもできます。式が never を返す場合、そのキーは結果から除外されます:

type WithoutId<T> = {
  [K in keyof T as Exclude<K, "id">]: T[K];
};

type NewOrder = WithoutId<Order>;
// { customerId: string; total: number; paid: boolean; note?: string }

ここの Exclude は条件付き型です。これは次のセクションのトピックです。

4. 条件付き型

4.1. extends ? : 構文

条件付き型は三項演算子のように機能しますが、型レベルで:

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

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

T extends string は「T を string に割り当てることができるか」という意味です。これはクラス継承の関係ではありません。

4.2. 分散性(Distributivity)

T が剥き出しの generic パラメータであり、union を渡す場合、TypeScript は条件を union の各メンバーに適用します:

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

type C = ToArray<string | number>;        // string[] | number[]
type D = ToArrayNonDist<string | number>; // (string | number)[]

2つの結果は全く異なります。T をタプル [T] でラップすると、分散性がオフになります。

分散性は Exclude と Extract の基盤です:

type MyExclude<T, U> = T extends U ? never : T;

type Status = "pending" | "paid" | "cancelled";
type ActiveStatus = MyExclude<Status, "cancelled">; // "pending" | "paid"

TypeScript は各メンバーを考慮します。"cancelled" は never に変わり、never は union から消えます。

注意: 分散条件付き型が never を受け取ると、never が返ります。never は空の union だからです。例えば、ToArray<never> は never[] ではなく never です。never を検出する必要がある場合は、ラップされたタプルフォームを使用してください:[T] extends [never] ? ... : ...。

4.3. infer で型を抽出

infer を使用すると、条件付きブランチ内の型の一部を「キャプチャ」できます:

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;

async function fetchOrder(id: string): Promise<Order> {
  return { id, customerId: "c-1", total: 0, paid: false };
}

type FetchResult = ReturnType<typeof fetchOrder>; // Promise<Order>
type FetchedOrder = UnwrapPromise<FetchResult>;   // Order
type Item = ElementOf<Order[]>;                    // Order

各部分の意味:

  • Promise<infer U> は型変数 U を宣言し、TypeScript に値を推測させます。
  • マッチした場合、true ブランチは U を使用できます。マッチしなかった場合、UnwrapPromise は T をそのまま返します。
  • ReturnType と Parameters も標準ライブラリでは infer を使用して書かれています。

実際には、Awaited<T> 組み込み型を使用する必要があります。これは Promise<Promise<Order>> のようなネストされた Promise も処理します。

5. 2つのテクニックの組み合わせ

5.1. 値の型によるプロパティの選択

conditional type を as 句に配置すると、値の型に基づいてキーをフィルタリングできます:

type PickByType<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

type OrderNumbers = PickByType<Order, number>; // { total: number }
type OrderStrings = PickByType<Order, string>; // { id: string; customerId: string }

note が OrderStrings に表示されないことに注意してください。Order["note"] の型は string | undefined で、strictNullChecks が有効な場合、この型は string に割り当てられません。ここで T[K] は剥き出しのパラメータではないため、条件は分散しません。Union はブロック全体として考慮されます。

5.2. 型付けされたイベントのハンドラ

これは実際のコードに近い例です。注文用のイベントテーブルがあり、ハンドラーの型を生成したいとします:

type OrderEvents = {
  created: { orderId: string; total: number };
  paid: { orderId: string; paidAt: Date };
  cancelled: { orderId: string; reason: string };
};

type EventHandlers<E> = {
  [K in keyof E as `on${Capitalize<string & K>}`]: (payload: E[K]) => void;
};

const handlers: EventHandlers<OrderEvents> = {
  onCreated: (p) => console.log(`注文を作成しました ${p.orderId}: ${p.total}`),
  onPaid: (p) => console.log(`支払い完了 ${p.paidAt.toISOString()}`),
  onCancelled: (p) => console.log(`キャンセル理由: ${p.reason}`),
};

handlers.onCreated({ orderId: "o-1", total: 250_000 });
注文を作成しました o-1: 250000

各ハンドラー内のパラメータ p は正しい型に推測されるため、型を再度宣言する必要がありません。OrderEvents に refunded イベントを追加すると、TypeScript は handlers に onRefunded がないため、すぐにエラーを報告します。

5.3. 生成された型のランタイム実装

Mapped type はコンパイル時にのみ存在します。実際のオブジェクトを持つには、ロジックを手動で作成する必要があります:

export function toGetters<T extends object>(obj: T): Getters<T> {
  const result: Record<string, () => unknown> = {};
  for (const key of Object.keys(obj) as (keyof T & string)[]) {
    const name = `get${key.charAt(0).toUpperCase()}${key.slice(1)}`;
    result[name] = () => obj[key];
  }
  return result as unknown as Getters<T>;
}

なぜキャストが必要なのですか?TypeScript は、ランタイムループが正確に Getters<T> の形状を作成することを証明できません。as unknown as キャストは、それを自分で保証する場所です。そのため、このコードは必ずテストが必要です。

6. Vitest での型テスト

Vitest には expectTypeOf があり、通常のランタイムアサーション と一緒に型をチェックできます:

import { describe, it, expect, expectTypeOf } from "vitest";
import type { Order } from "./order";
import { toGetters } from "./getters";

describe("utility types", () => {
  it("PickByType は一致する型のプロパティのみを保持します", () => {
    expectTypeOf<PickByType<Order, number>>().toEqualTypeOf<{ total: number }>();
    expectTypeOf<PickByType<Order, string>>()
      .toEqualTypeOf<{ id: string; customerId: string }>();
  });

  it("UnwrapPromise は Promise を1層剥きます", () => {
    expectTypeOf<UnwrapPromise<Promise<Order>>>().toEqualTypeOf<Order>();
    expectTypeOf<UnwrapPromise<number>>().toEqualTypeOf<number>();
  });

  it("toGetters は正しい値のゲッターを返します", () => {
    const getters = toGetters({ id: "c-1", name: "太郎", email: "taro@example.com" });

    expectTypeOf(getters.getName).returns.toEqualTypeOf<string>();
    expect(getters.getName()).toBe("太郎");
    expect(getters.getEmail()).toBe("taro@example.com");

    // @ts-expect-error ゲッターが存在しないことはコンパイルエラーであるべきです
    getters.getPhone;
  });
});

型チェック付きでテストを実行します:

npx vitest run --typecheck

次の点に注意してください:

  • ランタイムでは、expectTypeOf は何もしません。実際のチェックはコンパイラーがファイルを分析するときに行われます。したがって、--typecheck フラグが必要です。または、CI で npx tsc --noEmit を追加で実行してください。
  • // @ts-expect-error は否定的なテストに使用されます。下の行がエラーなくなった場合、例えば誰かが型をより緩くした場合、このディレクティブ自体がコンパイルエラーを引き起こします。
  • toGetters のランタイムテストは、ランタイムロジックが約束された型から逸脱するケースをキャッチするのに役立ちます。

7. よくあるエラーと使用しない時期

テンプレートリテラル型を使用するときに string & K を忘れる。 `get${Capitalize<K>}` と書くとエラーが発生します。K は number | symbol である可能性があるためです。

分散性に気づかない。 IsString<string | number> と書き、false を期待しますが、結果は boolean です(true | false)。union 全体をブロックとして考慮したい場合は、タプルでラップしてください。

any を持つ extends を混同する。 any により、条件は両方のブランチの和を返します。例えば、IsString<any> は boolean です。

非ホモモルフィック map を使用するときに修飾子を失う。 Mapped type [K in "id" | "total"]: Order[K] は Order の readonly や ? を保持しません。[K in keyof T] フォームのみが修飾子を保持します。いくつかのキーを選択しながら修飾子を保持したい場合は、Pick を使用してください。

再帰が深すぎる。 DeepPartial<T> のような型がネストされたオブジェクトに再帰を適用すると、Type instantiation is excessively deep and possibly infinite エラーが発生することがあります。また、エディターが明らかに遅くなります。さらに、Date、Map、配列も object なので、T[K] extends object 条件はしばしば誤ってそれらをキャッチします。

注意: mapped types と条件付き型を「スマートに見える」ためだけに使用しないでください。型が1か所でのみ使用され、ほとんど変わらない場合、手書きのインターフェースの方がはるかに読みやすいです。3〜4層ネストされた型からのエラーメッセージは、同僚には非常に難しいです。Partial、Pick、Omit、Record、Exclude、ReturnType、Awaited などの組み込みユーティリティを優先してください。

型がランタイムを保護すると信じる。 Mapped type は API からのデータをチェックしません。JSON がフィールドを欠いて返された場合、Getters<Customer> はコンパイル時には「正しい」ですが、ランタイムでは間違っています。外部からのデータの場合、ランタイムで検証ステップを追加する必要があります。

8. 結論

この記事では、TypeScript で型を生成するための2つのコアツールを紹介しました:

  • Mapped types。[K in keyof T] を使用して、キーをループし、readonly/? を追加または削除し、as で名前を変更し、キーをフィルタリングできます。
  • 条件付き型。T extends U ? X : Y を使用して、型レベルで分岐できます。これらは union に対して分散し、infer でサブタイプを抽出します。

2つのテクニックを組み合わせると、PickByType や EventHandlers のような型を書けます。これらの型は元の型が変わったときに自動的に更新されます。常に expectTypeOf と @ts-expect-error で型テストを含めてください。CI で --typecheck を実行してください。そして、これらの型を十分に単純に保ち、他の人が読んで理解できるようにしてください。

typescriptmapped typesconditional typesgenericstype system