TypeScriptのコードベースが大きくなると、似たような型定義を何度も書く場面が増えます。「全プロパティをオプショナルにした型」「読み取り専用にした型」「特定のキーだけ抽出した型」——こうした派生型を手動で管理していると、元の型を変更するたびに派生型も修正する必要が生じ、保守コストが膨らみます。
Mapped Typesは、既存の型を入力として受け取り、各プロパティに変換ルールを適用することで新しい型を自動生成する仕組みです。JavaScriptの Array.prototype.map がランタイムの配列を変換するように、Mapped Typesはコンパイル時の型を変換します。
構文の全体像:keyof・in・インデックスアクセスの三要素
Mapped Typesの構文は、JavaScriptの for...in ループに似た形式をしています。
type MappedType<T> = {
[K in keyof T]: T[K];
};
この構文を分解すると、3つの要素で構成されています。
| 構文要素 | 役割 | 対応するJS概念 |
|---|---|---|
K | 現在処理中のプロパティキー(型変数) | for...in のループ変数 |
in keyof T | T のすべてのキーを順に取り出す | オブジェクトのキーをイテレーション |
T[K] | キー K に対応する値の型(インデックスアクセス型) | obj[key] による値の取得 |
keyof T は型 T が持つプロパティ名のユニオン型を返す型演算子です。たとえば keyof { name: string; age: number } は "name" | "age" になります。
具体的な動きを確認します。
interface User {
name: string;
age: number;
email: string;
}
// User と構造が同一の型が生成される
type CopyOfUser = {
[K in keyof User]: User[K];
};
// 結果: { name: string; age: number; email: string; }
単にコピーするだけでは意味がありませんが、T[K] の部分を変更することで、値の型を自由に変換できます。
// 全プロパティの型を boolean に変換
type Flags<T> = {
[K in keyof T]: boolean;
};
type UserFlags = Flags<User>;
// 結果: { name: boolean; age: boolean; email: boolean; }
keyof とインデックスアクセス型の連携
Mapped Typesを効果的に使うには、keyof 演算子とインデックスアクセス型(T[K])の動作を正確に理解する必要があります。
interface Product {
id: number;
name: string;
price: number;
tags: string[];
}
// keyof で得られるユニオン型
type ProductKeys = keyof Product;
// 結果: "id" | "name" | "price" | "tags"
// インデックスアクセス型で特定キーの値型を取得
type ProductName = Product["name"];
// 結果: string
// ユニオン型でアクセスすると値型もユニオンになる
type ProductValues = Product[keyof Product];
// 結果: number | string | string[]
keyof の戻り値は文字列リテラル型のユニオンです。number 型のインデックスシグネチャを持つ型に対しては number も含まれます。
interface StringArray {
[index: number]: string;
}
type StringArrayKeys = keyof StringArray;
// 結果: number
修飾子の操作(Mapping Modifiers)
Mapped Typesでは、readonly と ?(オプショナル)の2つの修飾子を制御できます。+ で修飾子を追加、- で修飾子を除去します。+ は省略可能で、修飾子名だけ書くと追加を意味します。
readonly の追加・除去
// 全プロパティを読み取り専用にする
type Frozen<T> = {
+readonly [K in keyof T]: T[K];
};
// 全プロパティの読み取り専用を解除する
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
実際の使用例として、APIレスポンスを不変オブジェクトとして扱うケースがあります。
interface ApiResponse {
userId: number;
userName: string;
lastLogin: Date;
}
type ImmutableResponse = Frozen<ApiResponse>;
// 結果: {
// readonly userId: number;
// readonly userName: string;
// readonly lastLogin: Date;
// }
const response: ImmutableResponse = {
userId: 1,
userName: "tanaka",
lastLogin: new Date(),
};
// response.userName = "suzuki"; // コンパイルエラー
オプショナル修飾子の追加・除去
// 全プロパティをオプショナルにする
type Relaxed<T> = {
[K in keyof T]+?: T[K];
};
// 全プロパティを必須にする
type Strict<T> = {
[K in keyof T]-?: T[K];
};
フォーム入力のように、段階的にデータが埋まる場面で役立ちます。
interface RegistrationForm {
username: string;
email: string;
password: string;
}
// 入力途中の状態を表現する型
type PartialForm = Relaxed<RegistrationForm>;
// 結果: {
// username?: string;
// email?: string;
// password?: string;
// }
function saveProgress(draft: PartialForm): void {
// 途中保存処理
}
saveProgress({ username: "tanaka" }); // OK: 部分的でも有効
修飾子の組み合わせ
readonly と ? は同時に操作可能です。
// 全プロパティを readonly かつオプショナルにする
type FrozenPartial<T> = {
+readonly [K in keyof T]+?: T[K];
};
// readonly を外し、かつ必須にする
type MutableRequired<T> = {
-readonly [K in keyof T]-?: T[K];
};
Key Remapping(as 句によるキー変換)
TypeScript 4.1で導入された as 句を使うと、プロパティキーそのものを変換できます。
type Renamed<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
Capitalize はTypeScript組み込みのテンプレートリテラル型ユーティリティで、文字列の先頭を大文字にします。string & K は K が string 型であることを保証するための交差型です。
interface Config {
host: string;
port: number;
debug: boolean;
}
type ConfigGetters = Renamed<Config>;
// 結果: {
// getHost: () => string;
// getPort: () => number;
// getDebug: () => boolean;
// }
as 句によるプロパティのフィルタリング
as 句で never を返すと、そのプロパティは結果から除外されます。これを利用して、特定の条件に合うプロパティだけを抽出できます。
// 値の型が string であるプロパティだけを残す
type OnlyStringProps<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
interface Mixed {
name: string;
age: number;
email: string;
active: boolean;
}
type StringOnly = OnlyStringProps<Mixed>;
// 結果: { name: string; email: string; }
テンプレートリテラル型を重ねた応用
Key Remappingにテンプレートリテラル型を重ねると、変更通知コールバックの型定義を自動で導くといった応用が可能になります。
type PropertyChangeListeners<T> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (
next: T[K],
prev: T[K]
) => void;
};
interface ThemeConfig {
color: string;
fontSize: number;
darkMode: boolean;
}
type ThemeListeners = PropertyChangeListeners<ThemeConfig>;
// 結果: {
// onColorChange: (next: string, prev: string) => void;
// onFontSizeChange: (next: number, prev: number) => void;
// onDarkModeChange: (next: boolean, prev: boolean) => void;
// }
Conditional Typesとの連携
Mapped TypesとConditional Typesを組み合わせると、プロパティの型に応じた変換ロジックを記述できます。
// Date 型のプロパティは string に変換、それ以外はそのまま
type Serialized<T> = {
[K in keyof T]: T[K] extends Date ? string : T[K];
};
interface Event {
id: number;
title: string;
startAt: Date;
endAt: Date;
}
type SerializedEvent = Serialized<Event>;
// 結果: {
// id: number;
// title: string;
// startAt: string;
// endAt: string;
// }
JSONシリアライズの際に Date が string になる挙動を型レベルで表現でき、APIのリクエスト型とレスポンス型の変換に活用できます。
複数条件の分岐
Conditional Typesはネストして複数の条件を記述できます。
type FormFieldType<T> = {
[K in keyof T]: T[K] extends boolean
? "checkbox"
: T[K] extends number
? "number"
: T[K] extends Date
? "date"
: "text";
};
interface Task {
title: string;
priority: number;
completed: boolean;
dueDate: Date;
}
type TaskFormFields = FormFieldType<Task>;
// 結果: {
// title: "text";
// priority: "number";
// completed: "checkbox";
// dueDate: "date";
// }
この手法を使えば、オブジェクト定義からフォームの入力コンポーネントの型を自動的に導出できます。
標準ユーティリティ型を支えるMapped Typesの実装
TypeScriptが標準で提供するユーティリティ型の多くは、Mapped Typesで実装されています。実装を理解すると、独自のユーティリティ型を設計する際の指針になります。
| ユーティリティ型 | 内部実装 | 用途 |
|---|---|---|
Partial<T> | { [K in keyof T]?: T[K] } | 全プロパティをオプショナル化 |
Required<T> | { [K in keyof T]-?: T[K] } | 全プロパティを必須化 |
Readonly<T> | { readonly [K in keyof T]: T[K] } | 全プロパティを読み取り専用化 |
Pick<T, K extends keyof T> | { [P in K]: T[P] } | 指定キーのプロパティだけ抽出 |
Record<K, V> | { [P in K]: V } | キー集合と値型からオブジェクト型を生成 |
Omit<T, K> は Pick と Exclude の組み合わせで実装されています。
// Omit の内部実装
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
Exclude<T, U> はConditional Typesで実装されたユーティリティ型で、ユニオン型 T から U に代入可能な型を除去します。
// Exclude の内部実装
type Exclude<T, U> = T extends U ? never : T;
実務で使えるMapped Typesパターン集
パターン1: サーバー返却型から編集画面の入力型を派生させる
interface ArticleResponse {
articleId: number;
title: string;
body: string;
author: string;
publishedAt: Date;
revisedAt: Date;
}
// 自動採番フィールドと日時を除いた編集対象
type EditableArticle = Omit<ArticleResponse, "articleId" | "publishedAt" | "revisedAt">;
// 結果: { title: string; body: string; author: string; }
// 下書き保存用: 全フィールドオプショナル
type ArticleDraft = Partial<EditableArticle>;
// 結果: { title?: string; body?: string; author?: string; }
パターン2: 状態管理のローディングラッパー
type WithLoading<T> = {
[K in keyof T as `${string & K}Loading`]: boolean;
} & {
[K in keyof T as `${string & K}Error`]: string | null;
} & T;
interface DashboardData {
users: User[];
orders: Order[];
}
type DashboardState = WithLoading<DashboardData>;
// 結果: {
// usersLoading: boolean;
// ordersLoading: boolean;
// usersError: string | null;
// ordersError: string | null;
// users: User[];
// orders: Order[];
// }
パターン3: 型安全なバリデーションルール定義
type ValidationRules<T> = {
[K in keyof T]?: {
required?: boolean;
minLength?: T[K] extends string ? number : never;
maxLength?: T[K] extends string ? number : never;
min?: T[K] extends number ? number : never;
max?: T[K] extends number ? number : never;
pattern?: T[K] extends string ? RegExp : never;
};
};
interface SignUpForm {
username: string;
age: number;
bio: string;
}
const rules: ValidationRules<SignUpForm> = {
username: {
required: true,
minLength: 3,
maxLength: 20,
pattern: /^[a-zA-Z0-9_]+$/,
},
age: {
required: true,
min: 13,
max: 120,
},
bio: {
maxLength: 500,
},
};
パターン4: 深いネストのReadonly化(再帰的Mapped Types)
標準の Readonly<T> は1階層のみに作用します。ネストされたオブジェクトまで不変にするには、再帰的なMapped Typesが必要です。
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: DeepReadonly<T[K]>
: T[K];
};
interface Settings {
general: {
theme: string;
language: string;
};
notifications: {
email: boolean;
push: boolean;
};
}
type FrozenSettings = DeepReadonly<Settings>;
// general.theme も readonly になる
Function を除外している点がポイントです。関数も object の一種であるため、除外しないとメソッドの型が壊れます。
Mapped Typesの制約と注意点
追加プロパティの記述不可
Mapped Types内では、マッピングされたプロパティ以外のプロパティを直接追加できません。
// コンパイルエラー
type Invalid<T> = {
[K in keyof T]: T[K];
extra: string; // エラー: Mapped Typesには追加プロパティが書けない
};
追加のプロパティが必要な場合は、交差型(&)で結合します。
type WithMetadata<T> = {
[K in keyof T]: T[K];
} & {
_metadata: { updatedAt: Date };
};
ユニオン型のキーを使う場合の注意
keyof T ではなく、文字列リテラルのユニオンを直接指定する場合は Record に近い構文になります。
type HttpMethods = "GET" | "POST" | "PUT" | "DELETE";
type EndpointMap = {
[M in HttpMethods]: string;
};
// 結果: { GET: string; POST: string; PUT: string; DELETE: string; }
Record<HttpMethods, string> と等価ですが、値の型をキーに応じて変えたい場合はMapped Typesの構文が必要になります。
分配的な挙動への理解
Mapped Typesにユニオン型を渡すと、各メンバーに対して個別にマッピングが適用されます。タプル型を渡した場合はタプルの各要素に対してマッピングが行われます。
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
// タプルに適用
type NullableTuple = Nullable<[string, number]>;
// 結果: [string | null, number | null]
// 配列に適用
type NullableArray = Nullable<string[]>;
// 結果: (string | null)[]
Record型との関係
Record<K, V> は最もシンプルなMapped Typesの1つです。
// Record の内部実装
type Record<K extends keyof any, T> = {
[P in K]: T;
};
keyof any は string | number | symbol を意味し、有効なプロパティキーの型を表します。Record はキーの集合と値の型を指定してオブジェクト型を構築するため、辞書型やルックアップテーブルの定義に最適です。
type StatusLabel = Record<"active" | "inactive" | "pending", string>;
// 結果: { active: string; inactive: string; pending: string; }
const labels: StatusLabel = {
active: "有効",
inactive: "無効",
pending: "保留中",
};
まとめ
Mapped Typesは既存の型定義を入力として受け取り、プロパティ単位で変換ルールを適用する仕組みです。keyof による全キーの走査、+/- による修飾子の着脱、as 句によるキー名の変換、never によるプロパティの除外——これらを組み合わせることで、手動では管理しきれない派生型を安全に自動生成できます。
TypeScript標準のユーティリティ型(Partial、Required、Readonly、Pick、Omit、Record)はいずれもMapped Typesで実装されています。これらの内部実装を理解した上で、プロジェクト固有の型変換ロジック——APIレスポンスの加工、フォーム状態の管理、バリデーションルールの定義など——を独自のMapped Typesとして設計すると、型安全性と保守性が向上します。
