【テクニカル・上級編】 Mapped Typesでのreadonlyと?の操作 – TypeScript実践ガイド

こんにちは。フロントエンドのアーキテクチャの荒波をくぐり抜け、日々V8エンジンの機嫌とTypeScriptの型チェッカーの機嫌を伺いながらコードを書いている者です。

型システムというのは不思議なもので、最初は「安全性を担保する単なるコンパイル時のガードレール」だと思って侮っていると、プロジェクトが巨大化した瞬間に牙を剥いてきます。特に、APIレスポンスの形が刻々と変わり、ReduxやZustandといった状態管理のレイヤーでイミュータビリティを強制されるモダンなWebアプリ開発において、型変形(Type Transformation)のスキルはもはやシニアの必須教養です。

今回は、その中でもMapped Typesにおける修飾子操作――すなわち `readonly` と `?`(オプショナル)の付与と削除(`+`, `-` 修飾)について、実務で本当に役立つアーキテクチャの視点から深掘りしていきましょう。

—

なぜ「型の修飾子操作」がシニアの武器になるのか

大規模なフロントエンド開発において、最も避けるべき悪夢は何でしょうか?
それは、「データのライフサイクルごとに似たような型を何十個も定義し、バックエンドのスキーマ変更のたびに手動で追従させ、気がついたらコードベースが型定義のゴミ屋敷と化している」という状況です。

例えば、データベースから取得した厳格なエンティティ型(すべて `readonly` で必須プロパティ)があったとします。これをフォームの入力状態(すべてミュータブルで、かつ未入力があり得るためオプショナル)に変換したいとき、あなたならどうしますか?まさか手動で全部書き直したりしていませんよね?

ここで登場するのが、Mapped Typesにおける `+` と `-` の修飾子操作です。これらを使いこなすことで、単一の信頼できる情報源(Single Source of Truth)から、あらゆるレイヤーの型を自動生成できるようになります。

基礎の復習:`+` と `-` のメカニズム

TypeScriptの Mapped Types では、修飾子の前に明示的に符号をつけることができます。

  • `+readonly` または単に `readonly`:プロパティを読み取り専用にする
  • `-readonly`:プロパティの読み取り専用を解除し、ミュータブルにする
  • `+?` または単に `?`:プロパティをオプショナルにする
  • `-?`:プロパティのオプショナルを外し、必須(Required)にする

言葉で聞くよりも、実際のコードを見た方が早いでしょう。まずは、この修飾子操作がどのように型をねじ曲げるのか、実務的なユースケースベースで見ていきます。

実践:ReadOnlyなAPIレスポンスから、自由にいじれるフォーム用型への錬成

以下のコードを見てください。バックエンドから送られてくる厳格なユーザーエンティティを想定しています。

// バックエンドから返される、変更不可(readonly)かつ全項目必須のドメインモデル
type UserEntity = {
readonly id: string;
readonly email: string;
readonly role: ‘admin’ | ‘user’;
};

/

  • 【アーキテクチャ上の課題】
  • このUserEntityをそのまま画面のフォーム状態(React Hook Formなど)に持ち込むと、
  • 「途中段階ではメールアドレスが空かもしれない(オプショナル)」かつ
  • 「ユーザーが値を書き換えられる(ミュータブル)」必要がある。

/

// 修飾子を華麗に操作して、フォーム用の型を自動生成する
type FormState = {
// -readonly で読み取り専用を剥ぎ取り、+? でオプショナルにする
-readonly [K in keyof T]?: T[K];
};

type UserFormState = FormState;
/

  • 生成される UserFormState の実態:
  • {
  • id?: string;
  • email?: string;
  • role?: ‘admin’ | ‘user’;
  • }

/

このアプローチの美しいところは、`UserEntity` に新しいフィールド(例えば `readonly lastLoginAt: Date`)が追加された瞬間、何もしなくても `UserFormState` が自動的に追従する点です。メンテナンスコストは劇的に低下します。

—

パフォーマンスとメモリ効率、そして型チェッカーの負荷

ここで、少し低レイヤーな視点――TypeScriptコンパイラ(tsc)の内部挙動とパフォーマンスの話をしましょう。

「型定義なんてコンパイルが終われば消えるんだから、パフォーマンスに関係ないだろう」と思っていませんか?
実は、過剰に複雑なMapped Typesや、条件付き型(Conditional Types)のネストは、VS Codeのインテリセンス(Language Server)を重くし、CI環境での型チェック時間を数分単位で引き延ばす原因になります。

型の評価コストを抑える設計

TypeScriptの型チェッカーは、Mapped Typesを評価する際、内部でシンボルのルックアップとユニオン型の分配を行います。
特に、既存のユーティリティ型(`Partial`, `Readonly`, `Required`)を無計画に何重にも重ねがけすると、型エイリアスの解決パスが爆発し、IDEがフリーズする原因になります。

// 悪い例:ユーティリティ型を無駄にネストさせている
type BadState = Partial>;

// 良い例:必要な修飾子の操作を1つのMapped Type内で完結させ、コンパイラの負荷を最小化する
type OptimizedState = {
-readonly [K in keyof T]?: T[K];
};

実務の現場では、数千行規模の巨大なスキーマを扱うことがあります。コンパイラのメモリ効率を保つためにも、修飾子操作はできる限りフラットなMapped Type内でワンパスで処理するのが、大規模フロントエンド開発におけるプロの技です。

—

発展編:条件付き型(Conditional Types)と組み合わせた動的モデリング

さらに高度なアーキテクチャを目指すなら、Mapped Types内での修飾子操作を、プロパティの「型そのもの」の条件分岐と組み合わせるテクニックが不可欠です。

例えば、「特定の型を持つプロパティだけをミュータブルにし、それ以外は `readonly` のまま維持したい」といった、ドメイン駆動設計(DDD)における厳格な不変条件(Invariants)を強制したい場面を想像してください。

// 特定のプレフィックスを持つキー、あるいは関数型だけを操作する高度なMapped Type
type MutableNonFunctionKeys = {
-readonly [K in keyof T as T[K] extends Function ? never : K]: T[K];
};

type ComplexDomainModel = {
readonly id: string;
readonly version: number;
readonly save: () => void; // メソッド
};

type EditableFields = MutableNonFunctionKeys;
/

  • 結果:
  • {
  • id: string; // -readonly が適用され、ミュータブルになる(関数ではないため)
  • version: number; // 同上
  • // save メソッドは ‘as never’ によって型から消え去る
  • }

/

この `as` 節(Key Remapping)と修飾子操作(`-readonly`)のコンビネーションは、フレームワークの内部実装や、高度なライブラリの型設計(ZodやValibot、あるいは独自のRPCクライアントなど)において、ボイラープレートを消し去るための強力な武器になります。

—

現場でやりがちなアンチパターンと回避策

最後に、実務でこの機能を使う際によく見落としがちな「落とし穴」について共有しておきます。

1. `Partial` や `Required` の標準実装への過度な依存

TypeScript標準の `Partial` は、内部で `[K in keyof T]?: T[K]` と定義されています。これは便利ですが、「元の型が持っていた `readonly` 属性を勝手に剥ぎ取ってしまう」という挙動をする場合があります(※TypeScriptのバージョンや組み込み定義の進化によって挙動の微調整はありますが)。
意図しないミュータブル化を防ぐために、「既存の `readonly` を維持したままオプショナルにしたいのか」「`readonly` ごと剥がしたいのか」を意識し、標準ユーティリティを盲信せずカスタムMapped Typeを自製する勇気を持ちましょう。

2. オプショナル(`?`)と `undefined` の混同

`{ foo?: string }` と `{ foo: string | undefined }` は、型システム上は似て非なるものです。前者は「プロパティ自体が存在しなくてもよい(keyがundefined)」ですが、後者は「プロパティの存在は必須だが、値がundefinedである」ことを意味します。
Mapped Typesで `+?` を付与する際は、キーの存在有無がどう変わるのか、ランタイムのシリアライゼーション(JSON.stringifyなど)にどのような影響を与えるかまで想像を巡らせてください。

—

まとめ

TypeScriptの Mapped Types における `readonly` と `?` の操作(`+`, `-`)は、単なるシンタックスシュガーではありません。

それは、「単一のドメインモデルから、フロントエンドのあらゆるレイヤー(API、状態管理、UIフォーム、永続化)の型を完璧に導出するための、コンパイラをコントロールする権限」 です。

泥臭い手動の型定義や、コピペによる型の量産とは今日で決別し、型システムをあなたの意のままに飼い慣らした堅牢なアーキテクチャを構築してください。それでは、良きTypeScriptライフを。

コメント

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