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 を実行してください。そして、これらの型を十分に単純に保ち、他の人が読んで理解できるようにしてください。