【実務・中級編】 Mapped Typesでのreadonlyと?の操作 – TypeScript実践ガイド

お疲れ。最近、君が書いている型定義のコードをいくつかレビューさせてもらったんだけどさ……うん、`Partial`とか`Readonly`をただありがたがって使うだけのフェーズは、そろそろ卒業しようか。

中級からもう一歩先に進んで「型を意のままに操るアーキテクト」になるために、今日はMapped Types(マッピング型)における `readonly` と `?` の操作(付与・削除)について話をしよう。

ここをマスターすると、APIから返ってくる「全部オプショナルだけど本当は必須なレスポンス」や、「書き換えられたくないイミュータブルな設定値の一部を、一時的にMutable(可変)にしてバリデーションしたい時」などに、泥臭いハックを書かずに美しく型安全を担保できるようになる。

最後までついてきてくれ。

—

なぜ `+` と `-` の修飾子が必要なのか?

TypeScriptのMapped Typesを書くとき、既存の型をベースにしてプロパティをループさせるよね。基本形はこうだ:

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

でも、実務をやっていると「元の型が持っていても、この型では `readonly` を剥がしたい」「逆に、すべてのプロパティを強制的にオプショナル(`?`)にしたい、あるいはその逆がしたい」という壁に必ずぶ当たる。

ここで登場するのが、修飾子の前につける `+`(付与) と `-`(削除) のプレフィックスだ。

実は、何もつけずに `readonly [K in keyof T]` と書いた場合、これは `+readonly` と書いたのと同じ意味になる。つまり、デフォルトでは「プラス(追加)」が暗黙的に働いているんだ。
しかし、型から既存の制限を「剥ぎ取る」ためには、明示的に `-readonly` や `-?` と書いてやる必要がある。これが今日の核心だ。

—

ブラウザの裏側(コンパイル時)で何が起きているか?

ここでちょっと立ち止まって、TypeScriptが裏側でどう動いているかを知っておこう。
勘違いしている人が多いんだけど、TypeScriptの型システムはブラウザのランタイム(JavaScriptの実行環境)には一切存在しない。V8などのJSエンジンが動くときには、型はすべて綺麗に消し去られている(Type Erasure)。

じゃあ、Mapped Typesで `+` や `-` を使って型をイジっているとき、TypeScriptのコンパイラ(tsc)の中では何が起きているのか?

コンパイラは、AST(抽象構文木)を走査しながら、オブジェクト型のプロパティディスクリプタ(Property Descriptor)のメタデータを書き換えているんだ。
例えば、`-?` を指定したMapped Typesを評価するとき、コンパイラは「元の型にあるオプショナルフラグ(`optional: true` のような内部フラグ)を強制的にOFFにする」という計算を型空間で行っている。

つまり、ランタイムのパフォーマンスには1ミクロンも影響を与えない。だからこそ、俺たちは安心して複雑な型パズルを組んで、開発時のコンパイルエラーという最強のセーフティネットをノーコストで手に入れることができるというわけだ。

—

現場で即コピペして使える実践サンプルコード

百聞は一見にしかずだ。実際のフロントエンド開発でよくあるユースケースをベースにしたコードを見ていこう。エディタに貼り付けて挙動を確認してみてくれ。

/

  • 1. ユーザー情報のベース型
  • データベースやAPIから取得する、厳格なエンティティの定義。

/
type UserEntity = {
readonly id: string; // 変更不可のID
name: string;
email: string;
age?: number; // 最初からオプショナル
};

/

  • 【ユースケースA】
  • フォームの初期値や、部分的なパッチ送信(PATCHリクエスト)で使いたい!
  • 「すべてのプロパティをオプショナルにしつつ、readonlyも剥ぎ取りたい」場合。

/
type FormModel = {
-readonly [K in keyof T]-?: T[K];
};

// 適用結果のテスト
type UserFormValues = FormModel;
/
生成される型:
{
id: string; <- readonly が剥がれている! name: string; <- ? が付いてオプショナルになっている! email: string; <- ? が付いてオプショナルになっている! age: number; <- 元々 ? がついていたが、強制的に必須(あるいはオプショナルのまま)になる } / // 実戦での利用例 const updateForm: UserFormValues = { // idの書き換えも、一部のフィールドの欠落も型エラーにならない(フォーム用だからこれでOK) name: 'Yamada Taro', }; /

  • 【ユースケースB】
  • アプリケーションの設定(Config)管理。
  • 「外部から読み込んだ時はオプショナルかつイミュータブルだけど、
  • 内部の初期化ロジックを通った後は、絶対に書き換え不可(readonly)の完全な形にしたい」場合。

/
type AppConfig = {
endpoint?: string;
timeout?: number;
retries?: number;
};

// すべてのプロパティを必須(-?)にし、かつ読み取り専用(readonly)にする
type FreezeConfig = {
readonly [K in keyof T]-?: T[K];
};

type LockedConfig = FreezeConfig;
/
生成される型:
{
readonly endpoint: string; <- 必須になり、かつ readonly に! readonly timeout: number; <- 必須になり、かつ readonly に! readonly retries: number; <- 必須になり、かつ readonly に! } / // 利用例:初期化後は一切の改ざんを許さない const config: LockedConfig = { endpoint: 'https://api.example.com', timeout: 5000, retries: 3, }; // config.timeout = 10000; // <- ここで「Cannot assign to 'timeout' because it is a read-only property.」というコンパイルエラー!最高だろ? ---

シニアからの実践的なアドバイス(落とし穴とベストプラクティス)

この `+`/`-` の操作、めちゃくちゃ便利なんだけど、実務で使うときはいくつか気をつけてほしいポイントがある。

1. 標準ユーティリティ型との境界線を引く
TypeScriptには標準で `Partial`、`Required`、`Readonly` が用意されている。これらは内部でまさにこの `?` や `readonly` の操作をやっている(例えば `Required` は `-?` を使っている)。
だから、まずは標準のユーティリティ型で代用できないか考えよう。それを組み合わせても表現できない特殊なドメインモデル(「IDだけはreadonlyのまま、他は全部オプショナルにしたい」など)に直面したとき初めて、独自のMapped Typesを定義するべきだ。

2. 型パズルにしすぎない
あまりに複雑な条件分岐やマッピングを1つの型に詰め込みすぎると、VSCodeのホバー(IntelliSense)で型を見たときに、何がなんだか分からない巨大な型が表示されてチームメンバーが絶望する。「可読性はコードだけでなく、型定義にも宿る」ということを忘れないでほしい。

—

まとめ

Mapped Typesでの `readonly` と `?` の操作(`+` / `-`)は、フロントエンドの型設計を「受動的」から「能動的」に変えてくれる強力な武器だ。

APIのレスポンスやライブラリの型が自分たちの理想通りでなくても、このテクニックさえあれば、手元で自由自在に調理して最適な型にトランスフォームできる。

ぜひ今日の業務から、君のコードベースのあちこちにある「痒いところ」に、この `-readonly` や `-?` を適用してみてくれ。コードの堅牢性が一段階跳ね上がるのを実感できるはずだ。
さて、次のチケットに取り掛かろうか。何か詰まったらいつでも声をかけてくれよ。

コメント

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