Readonlyの幻想と、現場のTypeScriptアーキテクチャ
フロントエンドの規模が肥大化し、状態管理が複雑怪奇を極めると、我々を最も悩ませる悪夢は何だろうか?
そう、「意図しないミューテーション(状態変異)」だ。
Reduxのreducerでうっかりstateを直接いじってしまい、ReactのVDOM差分検出がバグり散らかす。あるいは、APIクライアントから返ってきた不変であるべきレスポンスオブジェクトを、下流のコンポーネントが勝手に書き換えてしまい、画面のあちこちで表示が矛盾を起こす……。
こういう「深夜のデバッグ大会」を、君も一度や二度、いや、エンジニア人生で数え切れないほど経験してきたはずだ。
TypeScriptの `Readonly
今日のテーマは、この `Readonly
ギークな視点から、その深層を紐解いていこう。
—
コンパイル時の幻影:JavaScriptランタイムとメモリ効率の現実
まず、大前提として心に刻んでおかなければならない事実がある。
`Readonly
JavaScriptのランタイムにトランスパイルされた瞬間、`Readonly
type User = {
id: number;
name: string;
};
// コンパイル時はエラーになるが…
const user: Readonly
// user.name = ‘Bob’; // Error: Cannot assign to ‘name’ because it is a read-only property.
// ランタイム(JS)では普通に書き換えが可能
(user as { name: string }).name = ‘Bob’;
console.log(user.name); // ‘Bob’ (平然と書き換わる)
「じゃあ、意味ないじゃん」と思ったそこの君。待ってほしい。
TypeScriptの真価は、ランタイムの保護ではなく、開発者の認知負荷の軽減と、IDEによる早期バグ検知にある。
しかし、もしあなたが「絶対にランタイムでも書き換えられたくない、堅牢なデータ構造を作りたい」と考えるなら、`Readonly
V8エンジンのガベージコレクタやメモリ効率の文脈において、不変性(Immutability)を担保するためには、型システムとランタイムの保護(`Object.freeze` や `structuredClone`)を組み合わせるアーキテクチャ設計が必要になる。
—
ネストしたオブジェクトの罠:浅い(Shallow)イミュータビリティの限界
`Readonly
つまり、トップレベルのプロパティしか読み取り専用にしてくれない。
type DeepUser = {
id: number;
profile: {
address: {
city: string;
};
};
};
type ReadonlyUser = Readonly
const john: ReadonlyUser = {
id: 1,
profile: {
address: {
city: ‘Tokyo’,
},
},
};
// これはちゃんとコンパイルエラーになる
// john.id = 2;
// しかし、ネストしたプロパティはスルーされてしまう!
john.profile.address.city = ‘Osaka’; // おっと、書き換えられてしまった!
大規模なフロントエンドアプリケーションでは、APIレスポンスは深くネストしたJSONツリーであることが多い。
この浅い `Readonly
解決策:DeepReadonlyの自作と、型パターンの極み
これを防ぐには、再帰的なユーティリティ型、通称 `DeepReadonly
// 再帰的にすべてのプロパティをReadonlyにする型
export type DeepReadonly
? ReadonlyArray
: T extends Function
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly
: T;
// 使用例
type SafeUser = DeepReadonly
const safeJohn: SafeUser = {
id: 1,
profile: {
address: {
city: ‘Tokyo’,
},
},
};
// トップレベルも、ネストした深部も、すべてコンパイル時に完璧にガードされる
// safeJohn.profile.address.city = ‘Osaka’; // Error!
この `DeepReadonly
—
非同期の競合と、状態管理(State Management)におけるアーキテクチャ
SPA(Single Page Application)のアーキテクチャにおいて、非同期通信(PromiseやObservable)と状態管理の競合は頭痛の種だ。
例えば、ユーザーがボタンを連打したことで、同一の非同期リクエストが複数走り、古いレスポンスが新しいレスポンスを上書きしてしまう「レースコンディション(競合状態)」は実務で頻発する。
ここで `DeepReadonly
// Zustandなどのストア定義を想定
interface AppState {
readonly currentUser: DeepReadonly
readonly isLoading: boolean;
// 状態の更新は必ず専用のセッター(アクション)を経由させる強制力を持たせる
fetchUser: (id: number) => Promise
}
データフローを一方向に強制する(Unidirectional Data Flow)上で、`Readonly
「誰がどこでこの状態を変えたんだ?」という泥沼のデバッグ作業から、君を解放してくれる最後の砦なのだ。
—
レンダリング最適化(Reactなど)との蜜月関係
最後に、フレームワークのパフォーマンス最適化の文脈に触れておこう。
Reactの `React.memo` や、VueのComputed properties、SolidJSのリアクティブシステムなど、現代のモダンフロントエンドフレームワークは「参照の同一性(Reference Equality)」をベースに無駄な再レンダリングを削ぎ落としている。
イミュータビリティが担保されているデータ構造(つまり `Readonly
もしデータがミュータブル(可変)であれば、変更を検知するために深いツリーの走査(Deep Equality Check)が必要になり、CPUに甚大な負荷をかけることになる。これはブラウザのメインスレッドをブロックし、Jank(カクつき)の原因となる。
つまり、`Readonly
—
まとめ
TypeScriptの `Readonly
しかし、その限界(シャローであること)を知り、`DeepReadonly
公式マニュアルを読んだだけで満足するな。
型システムの裏側にあるJavaScriptの挙動とメモリの世界に思いを馳せながら、より洗練されたコードを書き続けよう。
ハッピー・コーディング。

コメント