TypeScriptの「readonly」という幻想:コンパイル時防壁の深淵とランタイムの現実
TypeScriptの`readonly`修飾子を、単なる「値の書き換えを防ぐための便利な機能」と捉えているなら、それはあまりにナイーブだ。
我々がシステムアーキテクチャを設計する際、`readonly`はコンパイラという名の静的解析器に対する「型安全の契約」に過ぎない。V8エンジンがメモリ上のヒープをどう扱い、Node.jsのイベントループがそのメモリをどう走査するか。その本質を理解しなければ、セキュリティの脆弱性は常に「型定義の隙間」から忍び込む。
今日は、インターフェースにおける`readonly`と`Readonly
—
1. コンパイル時防壁としての「readonly」
インターフェース内の`readonly`は、TypeScriptの型チェッカー(`checker.ts`)がASTを走査する際、当該プロパティへの代入操作(`AssignmentExpression`)をエラーとしてマークするためのフラグだ。
interface ImmutableConfig {
readonly apiKey: string;
readonly endpoints: readonly string[];
}
const config: ImmutableConfig = {
apiKey: “secret”,
endpoints: [“/api/v1”]
};
// 1. コンパイルエラー: Property ‘apiKey’ is read-only.
// config.apiKey = “hacked”;
// 2. 注意点: 参照先自体はmutableである可能性が消えない
// (config.endpoints as string[]).push(“/v2”); // 型キャストで簡単に突破される
ここで重要なのは、「型定義は実行時には存在しない」というTypeScriptの基本原則だ。コンパイラが「readonlyだ」と叫んでも、JavaScriptに変換された瞬間にその制約は消滅する。`as any`や無理なキャストによる突破は、型安全性の防壁を容易に無効化する。
2. 構造的型付けと「readonly」の互換性
TypeScriptは「構造的型システム(Structural Typing)」を採用している。ここが`readonly`の落とし穴だ。
interface ReadOnlyUser {
readonly id: number;
}
interface MutableUser {
id: number;
}
const mutable: MutableUser = { id: 1 };
const readOnly: ReadOnlyUser = mutable; // 代入可能:readonlyは「読み取り専用」を強制するが「書き込みを禁止」するわけではない
// mutable経由で値を変更すると、readOnlyの方も変わってしまう
mutable.id = 2;
console.log(readOnly.id); // 2
この挙動は、大規模なアプリケーションで「不変なはずのオブジェクトがどこかで汚染される」というバグを生む最大の要因だ。`readonly`は対象のプロパティを「読み取り専用として扱う」という宣言であり、対象そのものが不変であるという保証ではない。
3. Readonly vs インターフェースのreadonly修飾子
`Readonly
type DeepReadonly
readonly [P in keyof T]: T[P] extends object ? DeepReadonly
};
真の不変性を求めるなら、この再帰的定義が必須となる。しかし、これを多用するとコンパイラの型推論負荷が指数関数的に増大する。大規模な複雑なオブジェクトグラフを扱う際、コンパイル時間が異常に長くなる原因の多くは、こうした深すぎる再帰型にある。
4. 低レイヤから見た「Immutability」と最適化
V8エンジンにおける不変性の真の価値は、単なるバグ防止ではない。「隠蔽された隠蔽(Hidden Classes)」の安定化だ。
オブジェクトの形状(Shape)が変わらないことは、V8がインラインキャッシュ(Inline Cache)を最大限に活用できることを意味する。書き換え可能なオブジェクトは形状の変化を予測させ、最適化を阻害する。一方、`readonly`を徹底し、データを「再生成」するスタイルを取ることは、GC(ガベージコレクション)の負荷とトレードオフになるが、ホットパスにおける推論の精度を極限まで高める。
セキュリティの観点
セキュリティ研究者が脆弱性を探す際、最も注視するのは「外部から注入されたオブジェクトが、想定外のタイミングで状態を変化させるケース」だ。
- イベントループのキュー消費: 非同期処理の合間に共有メモリの状態が書き換われば、レースコンディションが発生する。
- 防御的コピー: 外部APIレスポンスを受け取った際、直ちに`Object.freeze`を併用するか、再帰的なクローンを作成して「型定義上のreadonly」を「ランタイムのreadonly」に昇華させること。これが、堅牢なアーキテクチャの最低条件だ。
結論:コードの重みを知る者へ
TypeScriptの`readonly`は、開発者がコンパイラと交わす「信頼の約束」だ。しかし、ランタイムの闇を恐れるなら、その約束を盲信してはならない。
1. インターフェースの`readonly`は、型チェッカーへのヒントである。
2. ランタイムの不変性は、`Object.freeze()`やイミュータブルライブラリ(Immer等)で担保せよ。
3. 深い不変性が必要な場合のみ、再帰的Mapped Typesを使え。コンパイル時間はリソースだ。
型システムの極致とは、単にコードを書くことではない。そのコードがコンパイルを経て、メモリ上をどう駆け巡り、いかにして予期せぬ挙動を遮断するかを、脳内で常にシミュレートし続けることにある。
型安全とは、言葉(言語仕様)の上の遊びではない。それは、システムを守るための論理的な防壁そのものだ。