TypeScript不変性の幻想と現実:`readonly`修飾子 vs `Readonly
アーキテクトとして大規模なフロントエンド・ステート管理や、高頻度でイベントループを回すNode.jsバックエンドのコードベースを監査していると、いまだに「不変性(Immutability)」の担保を型システムの気休めに頼っているコードに遭遇する。
`readonly`修飾子を付ければ安全だと思い込み、あるいは`Readonly
TypeScriptの型システムは、コンパイルが完了した瞬間にすべて消滅する。
今回は、Interfaceの`readonly`修飾子と、ユーティリティ型`Readonly
—
1. コンパイラ内部における `readonly` と `Readonly` の正体
まず、TypeScriptコンパイラ(tsc)がこれらをどう扱っているかを知る必要がある。
Interfaceの `readonly` 修飾子
InterfaceやType Aliasのプロパティに付与される`readonly`は、パーサーがAST(抽象構文木)を構築する段階で、そのプロパティシンボルに対して `ModifierFlags.Readonly` フラグを立てる。
コンパイラは、型チェックのフェーズにおいて、このフラグがついたプロパティに対する代入式(Assignment expression)を検知すると、エラー(TS2540: Cannot assign to … because it is a read-only property.)を吐く。
interface ImmutableConfig {
readonly endpoint: string;
}
const config: ImmutableConfig = { endpoint: ‘https://api.example.com’ };
// コンパイルエラー: 割り当ては不可能
// config.endpoint = ‘https://malicious.example.com’;
`Readonly` ユーティリティ型
一方、`Readonly
type Readonly
readonly [K in keyof T]: T[K];
};
これはMapped Types(写像型)を用いた「型の変形」に過ぎない。元の型 `T` のすべてのキーをイテレートし、それぞれに強制的に `readonly` 修飾子を付与した新しい型エイリアスを生成している。
両者の決定的な違い:構造的型付け(Structural Subtyping)の罠
ここでシニアエンジニアが押さえておくべき極限の知見がある。それは「既存のミュータブルなオブジェクトを、`Readonly
interface MutableUser {
name: string;
settings: {
theme: string;
};
}
const user: MutableUser = {
name: ‘Architect’,
settings: { theme: ‘dark’ },
};
// Readonlyでラップする
const readOnlyUser: Readonly
// 1. トップレベルの再代入は防げる
// readOnlyUser.name = ‘Hacker’; // Error!
// 2. しかし、ネストされたプロパティはミュータブルのまま透過してしまう!
readOnlyUser.settings.theme = ‘light’; // 成功してしまう(型エラーにならない)
なぜこうなるのか? `Readonly
—
2. ディープ・イミュータビリティの強制:真の型安全とランタイムの防壁
ネストされたオブジェクト構造や、APIから非同期で流れてくるペイロード(Payload)を完全にイミュータブルとして扱うには、カスタムの再帰型(Recursive Mapped Types)を定義する必要がある。
// 再帰的にすべてのプロパティと配列をReadonly化する極限型
type DeepReadonly
? T
: T extends Map
? ReadonlyMap
: T extends Set
? ReadonlySet
: T extends object
? { readonly [K in keyof T]: DeepReadonly
: T;
この `DeepReadonly
—
3. ランタイムの現実:V8エンジンとメモリ最適化の裏側
TypeScriptでどれほど厳格に `readonly` や `Readonly
// コンパイル後のJavaScript (ES2022)
const user = {
name: ‘Architect’,
settings: { theme: ‘dark’ }
};
// 実行時にはただの通常のオブジェクトであり、書き換えは自由自在
user.settings.theme = ‘light’;
「それならば、ランタイム側でも不変性を強制するために `Object.freeze()` を使うべきか?」という議論に行き着く。
ここで、アーキテクチャ上の重要なトレードオフ(代償)が存在する。
`Object.freeze()` のパフォーマンスコストとV8の隠しクラス(Hidden Classes / Shapes)
V8エンジンは、オブジェクトのプロパティ構造(Shape)が一致する場合、同じ「Hidden Class」を割り当ててインラインキャッシュ(IC)を効かせ、プロパティアクセスの高速化を図る。
しかし、`Object.freeze()` を多用すると:
1. オブジェクトが拡張不能(Non-extensible)になり、V8の最適化パイプラインにおいて特定のインラインキャッシュがディモート(ダウングレード)されるケースがある。
2. 巨大なステートツリー(State Tree)に対して毎回深さ優先で `Object.freeze()` を呼び出すと、メインスレッドのイベントループをブロックし、高頻度な非同期処理のキュー消費に遅延(Jank)を引き起こす。
シニアエンジニアの最適解:型システムによる規律と、イミュータブル・データ構造の分離
高スループットが求められるバックエンドや、60fpsを死守すべきフロントエンドのステート管理(例: Redux ToolkitやZustandの内部設計)では、ランタイムの全件フリーズは行わない。
代わりに行うのは以下の設計だ:
- 開発時(Development): `DeepReadonly` 型と、イミュータブル更新ライブラリ(Immerなど、Proxyベースでコピーオンライイトを実現するもの)を組み合わせる。
- 本番時(Production): コンパイルタイムの型保証に全幅の信頼を置き、ランタイムのCPUサイクルを無駄なオブジェクト走査(`Object.freeze`)に消費させない。
—
4. イベントループと非同期処理におけるイミュータビリティの重要性
Node.jsのイベントループ、あるいはブラウザのマイクロタスクキュー(Microtask Queue)において、マルチステップの非同期処理を実行する際、参照が共有されたオブジェクトが途中で書き換えられる(Race Condition / 競合状態)のは、セキュリティ脆弱性やバグの温床となる。
以下のコードを見てほしい。非同期の処理待ち(`await`)の間に、外部からミュータブルなステートが書き換わってしまう悪夢のシチュエーションだ。
interface TransactionContext {
readonly userId: string;
readonly amount: number;
readonly metadata: {
ip: string;
};
}
async function processTransaction(ctx: DeepReadonly
// 非同期I/O(DBクエリや外部APIコール)
await logger.log(`Starting transaction for ${ctx.userId}`);
// もし ctx が DeepReadonly でなく、ただの mutable なオブジェクトであれば、
// この非同期境界の間に別の処理が ctx.metadata.ip を書き換えている可能性がある。
// DeepReadonly
await paymentGateway.charge(ctx.amount);
}
コンパイラレベルで `DeepReadonly` を強制している場合、非同期境界を跨いだとしても、「このデータの整合性はコンパイル時に保証されている」という強固な精神的・アーキテクチャ的安全性が手に入る。
—
5. まとめ:プロフェッショナルが選ぶべきベストプラクティス
1. 基本はInterfaceの `readonly` 修飾子
- シンプルなデータ構造や、DTO(Data Transfer Object)のトップレベルの不変性には、Interface定義に直接 `readonly` を付与する。これが最も直感的でIDEの補完も速い。
2. 構造全体の保証には `DeepReadonly
- 複雑なネストを持つ設定ファイルや状態ツリーには、ユーティリティ型 `Readonly
` ではなく、再帰的な `DeepReadonly ` を自作して適用する。
3. ランタイムのコストを測る
- `Object.freeze()` は万能の薬ではない。パフォーマンスクリティカルなパスでは、ランタイムのミュータビリティチェックを過信せず、イミュータブルな更新パターン(Copy-on-Write)を型とアーキテクチャの規律で維持せよ。
TypeScriptの型システムは、単なるコード補完の道具ではない。それは、複雑怪奇なJavaScriptのランタイム世界において、開発者が自らのコードベースを守るために築き上げた唯一無二の防壁である。その挙動の底を理解し、真の制御を手に入れろ。