【実務・中級編】オブジェクトのプロパティを動的に抽出する:Mapped Typesの基礎 – TypeScript コア・型システムの基礎解析バイブル

【TypeScript 掌握】型空間のループ処理を極める——Mapped Types の基礎と実務でバグを撲滅する動的型生成パターン

フロントエンド開発の現場において、APIレスポンスの型、フォームのバリデーション型、そして状態管理の型など、「本質的には同じデータ構造なのに、少しだけ属性(オプショナル、読み取り専用など)が異なる型」が乱立しているプロジェクトをよく見かけます。

もし、あなたがそれらを愚直に「手動でコピペして、一部を書き換えた新しい型」として定義しているなら、今すぐその手を止めてください。そのアプローチは、仕様変更のたびに型定義の同期漏れを引き起こし、静的型付けの恩恵を自ら放棄する「型安全性のサイレントテロ」に他なりません。

TypeScriptにおける真の型安全とは、「一箇所(Single Source of Truth)を変更すれば、関連するすべての型が自動的かつ整合性を保って追随する」設計を指します。そして、その中核を担うのが、既存の型を動的に走査・変換する「Mapped Types(マップ型)」です。

本記事では、このMapped Typesの内部メカニズムから、実務のフロントエンド・API連携で即戦力となる堅牢な設計パターンまで、コンパイラの挙動を踏まえて徹底的に解説します。

—

1. アンチパターン:なぜ「手動での型複製」はコードレビューで弾かれるのか

まずは、多くのプロジェクトで放置されがちな「リファクタリング対象」のコードを見てみましょう。

APIから取得するユーザー情報(`User`)と、それをフロントエンドの更新フォームやPATCHリクエストで送信する用のデータ型(`UpdateUserPayload`)を定義するケースです。

// ─── 1. 信頼できる唯一の情報源(Source of Truth) ───
interface User {
id: string;
name: string;
email: string;
age: number;
createdAt: Date;
}

// ─── 2. 【アンチパターン】手動で複製・加工された型 ───
// レビュー指摘:User型にフィールドが追加・変更された際、この型が追随できず、
// 実行時に未定義プロパティを送信するなどのバグ(ランタイムエラー)の原因になります。
interface UpdateUserPayload {
name?: string;
email?: string;
age?: number;
// id と createdAt は更新不可なので意図的に除外しているが、手動での管理は限界がある
}

この設計が孕む決定的な脆弱性

1. 変更コストの倍増(DRY原則の違反): `User`に`phoneNumber`が追加された瞬間、開発者は`UpdateUserPayload`にも手動でプロパティを追加しなければなりません。これを忘れると、コンパイルは通るのに、画面上で電話番号が更新できないという「サイレント・バグ」が発生します。
2. 意図の不明瞭さ: `UpdateUserPayload`が「`User`の一部を変更するための型である」という依存関係(文脈)が、型定義のコードから一切読み取れません。

これを解決するのが、型を「値」のようにプログラムで操作する、Mapped Typesの技術です。

—

2. Mapped Typesの基本:型空間における反復処理のメカニズム

Mapped Typesとは、一言で言えば「型空間における`Array.prototype.map`」です。オブジェクトのキー(プロパティ名)の集合をループ処理し、新しいプロパティ名と新しい型を動的に生成します。

その最も基本的な構文を解剖してみましょう。

type MappedType = {
[K in keyof T]: T[K];
};

一見難解に見えますが、要素を分解して考えると極めてシンプルです。

[ K in keyof T ] : T[K]
▲ ▲ ▲
│ │ └─ 3. プロパティの型(ルックアップ型)
│ └────────────── 2. Tのプロパティ名のユニオン型(例: “id” | “name” | “email”)
└──────────────────────── 1. ループ内の現在のキー(変数)

1. `keyof T`: 対象となる型 `T` が持つすべてのキーを、文字列リテラルのユニオン型(例:`”id” | “name” | “email”`)として抽出します。
2. `in`: ユニオン型から一つずつキーを取り出し、一時変数 `K` に代入しながらループ処理を行います(JavaScriptの `for…in` ループと同等です)。
3. `T[K]`: 「Lookup Types(ルックアップ型)」と呼ばれ、型 `T` におけるプロパティ `K` の型を動的に参照します。

この処理により、元の型と全く同じ構造を持つ新しい型が再構築されます。これだけでは等価変換に過ぎませんが、ここに「モディファイア(修飾子)」を組み合わせることで、型の性質を劇的に変化させることができます。

—

3. 実戦応用:モディファイア(`+`, `-`, `readonly`, `?`)の精密制御

Mapped Typesの真骨頂は、プロパティに対する「読み取り専用(`readonly`)」や「オプショナル(`?`)」といった修飾子を、一括で「追加」または「削除」できる点にあります。

これらを制御するために、明示的な接頭辞として `+`(追加・デフォルト)や `-`(削除)を使用します。

すべてのプロパティから「オプショナル(`?`)」を剥ぎ取る:`-?`

APIレスポンスの段階では未確定(オプショナル)だったデータを、ローカルの状態管理クラスやコンポーネント内で「完全に確定したデータ(必須)」として扱いたい場合、`-?` を用いてオプショナル属性を強制排除します。

// 元の型(すべてがオプショナル)
interface PendingUser {
id: string;
name?: string;
email?: string;
}

// Mapped Typesによる「厳格化」
type Concrete = {
[P in keyof T]-?: T[P]; // 「-?」により、すべての「?」が剥ぎ取られる
};

// コンパイラによって以下のように評価される:
// type StrictlyUser = {
// id: string;
// name: string; // 必須化
// email: string; // 必須化
// }
type StrictlyUser = Concrete;

// テスト検証
const validUser: StrictlyUser = {
id: “u101”,
name: “Alice”,
email: “alice@example.com” // これを欠くとコンパイルエラーになる
};

すべてのプロパティから「読み取り専用(`readonly`)」を解放する:`-readonly`

外部ライブラリなどで定義された不変(`readonly`)オブジェクトを、フロントエンドのローカルステートや下層のフォームコンポーネントに渡すために、一時的に書き込み可能(Writable)な型に変換したい場合、`-readonly` を使用します。

interface ImmutableConfig {
readonly apiEndpoint: string;
readonly timeout: number;
}

// 読み取り専用を解除するユーティリティ
type CreateWritable = {
-readonly [P in keyof T]: T[P]; // 「-readonly」により、readonly属性を削除
};

type WritableConfig = CreateWritable;

const config: WritableConfig = {
apiEndpoint: “https://api.example.com”,
timeout: 5000
};

// 読み取り専用が解除されているため、再代入が可能
config.timeout = 10000; // コンパイル成功!

—

4. プロダクションコード例:非同期API連携とコンポーネント設計を繋ぐ実用パターン

それでは、これまでの知見を統合し、実際の開発現場で強力な威力を発揮する、堅牢で美しいプロダクションコードの実例を示します。

シナリオ:ドメインモデルから「部分更新(Patch)API用」および「クライアント用UI状態」の型を動的に導出する

APIから返却される厳格なエンティティ `User` を起点とし、以下の2つの派生型を一切の手動コピペなしで自動生成します。

1. `UserPatchRequest`: `id` 以外のすべてのプロパティを更新可能(オプショナル)とし、さらに `readonly` なプロパティの書き込みを制限する。
2. `FormState`: 各フィールドの入力状態とバリデーションエラーを追跡するための、UI特有のメタデータ構造を動的に生成する。

// ─── 1. ドメインモデル(Single Source of Truth) ───
export interface User {
readonly id: string; // IDは絶対に書き換え不可
name: string;
email: string;
age: number;
isActive: boolean;
}

// ─── 2. 汎用ユーティリティ:キーの除外とマッピング ───
// 「Omit」と「Mapped Types」を組み合わせ、特定のキー(id)を除外した上で、
// 残りのプロパティをすべてオプショナル(?)にする
export type CreatePatchPayload = {
[P in keyof Omit]+?: Omit[P];
};

// ─── 3. 動的に生成されたPatchリクエスト型 ───
// 「id」を除外した上で、他のすべてのフィールドがオプショナルかつ、
// 元の型定義の「型(stringやnumberなど)」がそのまま維持される。
export type UserPatchRequest = CreatePatchPayload;

/
コンパイラによる「UserPatchRequest」の内部評価結果:
type UserPatchRequest = {
name?: string;
email?: string;
age?: number;
isActive?: boolean;
}
/

// ─── 4. クライアントUI用の状態メタデータ型 ───
// 任意のデータオブジェクトを受け取り、各プロパティごとに
// 「現在の値」「編集されたか(dirty)」「エラーメッセージ」を持つオブジェクトへと変換する
export type FormState = {
readonly [P in keyof T]: {
value: T[P];
isDirty: boolean;
error: string | null;
};
};

// ─── 5. 実装コードにおける適用例 ───

// API送信関数の定義
async function patchUser(userId: string, payload: UserPatchRequest): Promise {
// 型安全なリクエスト処理。
// payload.id は存在しない(コンパイルエラーになる)ため、誤ってIDを上書き送信するバグを未然に防ぐ。
await fetch(`/api/users/${userId}`, {
method: “PATCH”,
body: JSON.stringify(payload),
headers: { “Content-Type”: “application/json” }
});
}

// フロントエンドコンポーネントでのフォーム状態管理
const userFormState: FormState> = {
name: { value: “John Doe”, isDirty: false, error: null },
email: { value: “invalid-email”, isDirty: true, error: “無効なメールアドレスです” },
age: { value: 30, isDirty: false, error: null },
isActive: { value: true, isDirty: false, error: null }
};

// テスト実行(コンパイラによる静的検証)
const handleSave = () => {
const requestBody: UserPatchRequest = {};

// dirtyなフィールドのみを抽出してリクエストを構築(型安全)
if (userFormState.name.isDirty) {
requestBody.name = userFormState.name.value;
}
if (userFormState.email.isDirty && !userFormState.email.error) {
requestBody.email = userFormState.email.value;
}

patchUser(“user_999”, requestBody);
};

このコードの素晴らしい点は、`User` インターフェースに新しいプロパティ(例: `role: string`)が追加された瞬間に、`UserPatchRequest` も `FormState>` も、コンパイルエラーを出しながら自動的にその追随を要求する点にあります。

開発者はどこを修正すべきかコンパイラから直接指示されるため、コードの書き換え漏れによるバグは原理的に発生しなくなります。

—

5. アーキテクトが語るパフォーマンスと設計のトレードオフ

Mapped Typesは非常に強力な武器ですが、銀の弾丸ではありません。大規模なコードベースにおけるコンパイルパフォーマンス(型評価の速度)に配慮した設計指針を共有します。

型の過度なネストに注意せよ

TypeScriptコンパイラ(`tsc`)は、型を評価する際に抽象構文木(AST)を展開し、再帰的に型計算を行います。Mapped Typesの中にさらに別のMapped Typesや、複雑なConditional Types(条件付き型)を無尽蔵にネストさせると、型チェッカーのメモリ消費が指数関数的に増大します。

  • 症状: VS Codeの型定義ホバー(ツールチップ)の表示が数秒遅れる、セーブ時の型検査が重い。
  • 対策: 汎用的な型ユーティリティ(`CreatePatchPayload` など)を作る際は、「一目で処理内容がわかる程度」に分割し、必要以上にジェネリクスを深くネストさせないようにしてください。

`interface` と `type` の適切な使い分け

Mapped Typesは `type`(型エイリアス)でしか宣言できません。しかし、型定義の「拡張性(拡張可能性)」を担保したい場合(他のファイルで同名の型をマージしたい場合など)は、`interface` が有利です。

基本的には、ドメインモデルの根幹(Source of Truth)は `interface` で定義し、それを射影・加工して一時的なペイロードを作るユーティリティやコンポーネント用の型定義に `type`(Mapped Types)を使用する、という棲み分けがベストプラクティスです。

—

6. 結論

  • 手動での型複製は百害あって一利なし。ドメインモデルを唯一の真実(Source of Truth)とせよ。
  • Mapped Typesは、型空間における `map` 処理であり、`[K in keyof T]` という直感的なループ構文を持つ。
  • `+` や `-`、`readonly` や `?` を適切にハンドリングすることで、不変オブジェクトの書き込み可能化や、未確定データの必須化を柔軟に制御できる。
  • Mapped Typesによる動的型生成を設計に組み込むことで、「一度定義したドメインモデルが、システム全体の型安全性を牽引する」極めて堅牢なアーキテクチャが完成する。

手書きの型定義から脱却し、TypeScriptの真のパワーをプロジェクトに注入しましょう。あなたのコードの保守性は、これだけで劇的に向上します。

タイトルとURLをコピーしました