【コードレビューの現場から】`const`の幻想を断つ:オブジェクトの可変性とイミュータブル設計の極限
コードレビューをしていると、未だに以下のような誤解に基づいたコードに出くわすことがある。
// レビュー対象コード
const user = { name: ‘Alice’, role: ‘admin’ };
user.role = ‘guest’; // 「constだから変更できないはずなのに…」という勘違い
「`const`で宣言しているから、このオブジェクトは安全に保護されている」――もし君がそう考えているなら、V8エンジンのメモリ構造とJavaScriptの言語仕様の深層をもう一度見つめ直す必要がある。
結論から言おう。`const`が固定しているのは「オブジェクトの中身」ではなく、「ポインタ(メモリ参照アドレス)」そのものだ。
今回は、フロントエンドのコンポーネント設計や非同期API連携の現場において、予期せぬ副作用(Side Effect)やバグを根絶し、予測可能性の高い堅牢なコードベースを築き上げるためのイミュータブル設計の極限を伝授する。
—
1. V8エンジンのメモリ空間で何が起きているか
JavaScriptにおいて、プリミティブ型(`string`, `number`, `boolean`など)の値はコールスタック上に直接値が格納される。そのため、`const`で宣言されたプリミティブ値は再代入が不可能であり、文字通り「定数」として振る舞う。
しかし、オブジェクトや配列などの参照型(Reference Type)は話が違う。
コールスタックに置かれるのは「ヒープメモリ上の実体アドレスを指す参照(ポインタ)」にすぎない。
[Call Stack] [Heap Memory]
const user —-(参照)—–> { name: ‘Alice’, role: ‘admin’ }
`const user`という宣言は、「`user`という変数名が指すアドレスの書き換えを禁止する」ものでしかない。ヒープメモリ上に存在するオブジェクトのプロパティを書き換える行為(`user.role = ‘guest’`)は、ポインタの宛先が変わらないため、V8のパーサーやコンパイラからすれば何ら規約違反ではないのだ。
この言語仕様の特性を理解していないと、大規模なフロントエンドアプリケーションにおいて「意図しないオブジェクトのミューテート(破壊的変更)」を引き起こし、UIの再描画バグや状態管理の崩壊を招くことになる。
—
2. `const` vs `Object.freeze()`:防御のレイヤーの違い
「中身も変更したくないなら、すべて`Object.freeze()`を使えばいいのか?」という疑問が湧くだろう。
ここで、`const`と`Object.freeze()`の決定的な違いを整理しておく。
| 比較項目 | `const` | `Object.freeze()` |
| :— | :— | :— |
| 対象 | 変数の束縛(Binding) | オブジェクトの値(Value) |
| 制限内容 | 再代入の禁止 (`user = {}` はエラー) | プロパティの追加・削除・変更の禁止 |
| 評価タイミング | コンパイル時 / 静的解析 | 実行時(Runtime) |
| 深さ | 浅い(Shallow) | 浅い(Shallow) ※ネストしたオブジェクトは凍結されない |
ここで最大の罠となるのが、どちらも「シャshallow(浅い)」な保護しか提供しないという点だ。
const config = Object.freeze({
api: {
timeout: 3000,
endpoint: ‘https://api.example.com’
}
});
// Object.freezeを使っているにもかかわらず、これは成功してしまう!
config.api.timeout = 5000;
console.log(config.api.timeout); // 5000 (書き換わっている)
`Object.freeze()`は第一階層のプロパティしか凍結しない。ネストされたオブジェクトのイミュータビリティを担保するためには、再帰的な凍結関数(Deep Freeze)を実装するか、モダンなイミュータブルライブラリ(Immerなど)、あるいは言語仕様の進化を待つ必要がある(将来的にはDeep Immutableの提案も進んでいるが、実務では自衛が必要だ)。
—
3. 実務で使える:堅牢なディープフリーズ実装パターン
プロダクションコードにおいて、APIレスポンスなどの外部入力を安全にアプリケーションのステートに組み込むための、実用的なユーティリティ関数を見てみよう。
/
- オブジェクトを再帰的に凍結し、完全なイミュータビリティを担保する
- @param {Object|Array} target – 凍結対象のオブジェクトまたは配列
- @returns {Object|Array} 凍結されたオブジェクト
/
function deepFreeze(target) {
// nullまたはオブジェクト/配列以外の場合はそのまま返す
if (target === null || typeof target !== ‘object’) {
return target;
}
// すでに凍結されている場合はスキップ(循環参照対策)
if (Object.isFrozen(target)) {
return target;
}
// オブジェクトのプロパティ名(Symbol含む)を取得
const propNames = Reflect.ownKeys(target);
// 各プロパティに対して再帰的にdeepFreezeを適用
for (const name of propNames) {
const value = target[name];
if (value && (typeof value === ‘object’ || typeof value === ‘function’)) {
deepFreeze(value);
}
}
return Object.freeze(target);
}
// — 使用例 —
const appState = deepFreeze({
user: {
id: 42,
permissions: [‘read’, ‘write’]
},
settings: {
theme: ‘dark’
}
});
// 厳格モード(strict mode)下では TypeError がスローされる
// 非厳格モードでも値は書き換わらない
try {
appState.user.permissions.push(‘execute’);
} catch (e) {
console.error(`イミュータビリティ違反を検知: ${e.message}`);
}
このパターンをAPIクライアントのレイヤーや、Redux/Zustandなどのステート管理の初期化フェーズに組み込むことで、不正なミューテーションを開発者のうっかりミスも含めてランタイムで完全にシャットアウトできる。
—
4. パフォーマンスとDOM操作、配列処理における注意点
「じゃあ、すべてのオブジェクトを毎回`deepFreeze`したり、スプレッド構文でコピーすればいいのか?」というと、それは大きなパフォーマンスのアンチパターンになり得る。
1. ガベージコレクション(GC)とV8のヒープ負荷
不必要にオブジェクトや配列のクローン(`{ …obj }` や `[…arr]`)を大量生成すると、V8のヒープメモリ上に無数の短命なオブジェクトが生成され、ガベージコレクタ(GC)の稼働頻度が跳ね上がる。特にミリ秒単位の滑らかなレンダリングが求められるUIアニメーション中や、高頻度なスクロールイベントのハンドラ内での過剰なイミュータブル操作は、メインスレッドをブロックし、Jank(カクつき)の原因となる。
2. 配列メソッドの選定コスト
`Array.prototype.push()` などの破壊的メソッドは高速だが、イミュータブルを保つために `Array.prototype.concat()` やスプレッド構文 `[…arr, newItem]` を使う場合、O(n)のメモリコピーコストが発生する。
数万件の要素を持つ巨大な配列に対してこれを高頻度で行うのは愚行である。データ構造の規模(Scale)を見極め、イミュータブルの恩恵とパフォーマンスのトレードオフを常に意識せよ。
—
5. 結論:モダンフロントエンドにおける設計の指針
チーム開発において、バグの温床となる「予期せぬ状態変化」を防ぐための指針を以下にまとめる。
1. デフォルトは`const`:変数の再代入は原則禁止し、意図的な再代入が必要な場合のみ(ループのカウンタや累積変数など)に限り最小限のスコープで`let`を使用する。
2. オブジェクトの不変性はフローで担保する:単に`const`にするだけでなく、関数の引数や戻り値において「破壊的変更を行わない(Pure Functionの維持)」をコード規約とTypeScriptの型定義(`Readonly
3. 境界線(Boundary)でのみ防御する:APIからのレスポンス境界や、グローバルステートのイニシャライザなど、「信頼できない外部データがシステム内に入ってくる境界」で`Object.freeze`や`deepFreeze`を適用し、内部ロジックは常に安全なイミュータブルデータとして扱う。
JavaScriptのランタイム特性を理解した上での設計こそが、プロダクトの寿命を延ばし、保守性の高いコードベースを維持する唯一の道である。感覚ではなく、物理的なメモリの挙動に基づいたコードを書き続けよう。