コードレビューの現場で:なぜ `let` を捨て `const` を信仰するのか
コードレビューをしていて、未だに全変数を `let` や `var` で宣言しているプルリクエストを見ると、V8エンジンのコンパイラ最適化の恩恵を自らドブに捨てているようで、思わず深呼吸したくなる。
「再代入しないから `const` にする」——そんな教科書通りの動機で `const` を使っているうちは、まだモダニティの表面しか見えていない。
私たちが `const` を選ぶ本当の理由は、「この変数の値は、スコープの生存期間中において絶対に変動しない」という不変の契約(Immutability Contract)を、処理系(V8)と人間の脳の両方に静的保証するためだ。
変数の書き換えを言語レベルで禁止することで、コードの「時間的な変化(Temporal Mutation)」を排除し、コードベースを完全な「宣言的パラダイム」へと昇華させる。この思想を持てるかどうかが、プロダクションのコードベースを「スパゲッティの温床」から「予測可能な要塞」に変える境界線なのだ。
—
1. 宣言的プログラミングとV8の隠しクラス最適化
命令型プログラミング(`let` で変数を宣言し、ループや条件分岐で値やプロパティをゴリゴリ書き換えるスタイル)は、V8エンジンのJITコンパイラにとっても頭痛の種だ。変数が動的に書き換えられる可能性を常に考慮せねばならず、インラインキャッシュ(IC)の最適化が効きにくくなる。
一方、`const` によって参照先(Reference)が固定されたオブジェクトや配列、あるいはプリミティブ値は、スコープ内での「予期せぬ状態遷移」の可能性がゼロになる。これにより、認知負荷が劇的に下がる。コードを読む人間は、「この変数の値はどこかで変わったか?」という疑念を持つ必要が一切なくなるからだ。
ただし、ここで JavaScript の仕様における大きな罠がある。`const` は「値の不変性(Value Immutability)」ではなく「束縛の不変性(Binding Immutability)」を保証するに過ぎない。
// constであってもオブジェクトのプロパティは書き換え可能(ミュータブル)
const user = { name: ‘Alice’, role: ‘engineer’ };
user.role = ‘architect’; // これはエラーにならない!
この仕様のせいで「どうせ中身を変えられるなら `const` も `let` も同じだ」と誤解するジュニアエンジニアが後を絶たない。しかし、ここにこそ設計の妙がある。「再代入の禁止」を強制すること自体が、データフローを単方向(Unidirectional)に強制するための強力な制約(ガードレール)として働くのだ。
—
2. 実務リファクタリング事例:命令型から宣言的コードへの昇華
実際の現場でよく見かける、非同期API連携とDOM/状態管理が混ざった「最悪の命令型コード」をリファクタリングしてみよう。
❌ 悪臭を放つプロダクションコード(`let` と状態ミューテーションの嵐)
// 【アンチパターン】どこで何が書き換わっているか追跡不可能なレガシーコード
let userList = [];
let isLoading = false;
let errorMessage = null;
async function fetchAndProcessUsers(apiEndpoint) {
isLoading = true;
errorMessage = null;
try {
const response = await fetch(apiEndpoint);
if (!response.ok) throw new Error(‘Network response was not ok’);
let rawData = await response.json();
// 配列を直接破壊的に加工・フィルタリング
rawData.forEach(user => {
user.displayName = `${user.lastName}, ${user.firstName}`;
if (user.status === ‘INACTIVE’) {
// 配列の途中でスプライスするなど言語道断
// メモリ上の配列構造が変わり、V8の要素Kind(Elements Kind)が降格する
}
});
userList = rawData.filter(u => u.status === ‘ACTIVE’);
} catch (err) {
errorMessage = err.message;
userList = [];
} finally {
isLoading = false;
renderUI(); // グローバルな状態を元にDOMを再描画
}
}
このコードの問題点は、`let` の乱用により、モジュールスコープの状態が時系列でグチャグチャに書き換わっていくことだ。V8のメモリヒープ上でも、オブジェクトの形状(Hidden Class)が頻繁に変更され、ガベージコレクションや最適化の効率を著しく落とす。
💎 圧倒的に堅牢なモダンコード(`const` とイミュータブルなパイプライン設計)
これを、一切の再代入を行わず、関数型アプローチを取り入れた「宣言的コード」にリファクタリングする。
/
- ユーザーデータを安全に取得・加工する純粋関数的なアプローチ
- @param {string} apiEndpoint
- @returns {Promise<{ users: Array, isLoading: boolean, error: string|null }>}
/
async function fetchAndProcessUsers(apiEndpoint) {
// 状態の「遷移」ではなく、状態の「スナップショット(計算結果)」をconstで定義する
const state = {
users: [],
isLoading: true,
error: null
};
try {
const response = await fetch(apiEndpoint);
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const rawData = await response.json();
// 破壊的メソッド(forEachやsplice)を排除し、
// メソッドチェーン(map, filter)で新しい配列とオブジェクトをイミュータブルに生成する
const activeUsers = rawData
.filter(user => user.status === ‘ACTIVE’)
.map(user => {
// 元のオブジェクトを汚染(Mutation)せず、スプレッド構文で新しいオブジェクトを構築
// V8はこのオブジェクトの形状を静的にコンパイルしやすくなる
return {
…user,
displayName: `${user.lastName}, ${user.firstName}`,
processedAt: Symbol.for(‘timestamp’) // 必要に応じたメタデータ付与
};
});
return {
…state,
users: activeUsers,
isLoading: false
};
} catch (err) {
// エラー時も新たな状態オブジェクトを返す(例外をローカルに閉じ込める)
return {
…state,
error: err.message,
isLoading: false
};
}
}
この設計において、`let` はもはや一行も必要ない。すべての変数は `const` で宣言され、値は宣言と同時に確定するか、あるいは非同期処理の結果として一度だけ代入(初期化)される。
—
3. パフォーマンスとメモリ効率の真実
「毎回新しいオブジェクトや配列を作る(イミュータブルにする)と、メモリの無駄遣いになり、ガベージコレクション(GC)の負荷が上がるのではないか?」
フロントエンド・アーキテクトであれば誰もが一度は抱く懸念だ。しかし、現代の V8 ランタイム(Ignition インタープリタと TurboFan オプティマイザ)の挙動を理解していれば、その懸念は杞憂だと気づく。
1. 短期生存オブジェクト(Young Generation GC)の高速性:
モダンブラウザのV8ヒープは、世代別ガベージコレクションを採用している。関数内で生成され、すぐにスコープ外になるイミュータブルな一時オブジェクト(`map` や `filter` で作られる配列など)は、大半が「新生代(New Space)」に割り当てられる。新生代のGCは極めて高速(Scavengerアルゴリズム)であり、ミリ秒単位の描画フレームレート(60fps / 120fps)を阻害することはまずない。
2. インラインキャッシュとハイドレーション:
`let` で中身が随時書き換わるオブジェクトは、V8が型推論を諦め、ハッシュマップベースの低速なプロパティアクセスにフォールバックする。一方、`const` で宣言され、イミュータブルに構築されたオブジェクトは、プロパティの構造(Hidden Class)が固定されるため、メモリアドレスのオフセット計算による高速なプロパティアクセス(C言語の構造体に近いアクセス)が維持される。
—
4. チーフアーキテクトからの実践的プラクティス
明日からのコードレビュー、あるいは新規設計で以下のルールをチームに徹底してほしい。
1. デフォルトは常に `const`:
変数を打つときは指が勝手に `const` と入力されるように筋肉に覚え込ませる。`let` を使うのは、`for (let i = 0; i < n; i++)` のループカウンターや、アルゴリズム上どうしても再代入が不可避なアキュムレータ(リデューサーの内部変数など)に限定する。`var` は語る価値すらない、レガシーの遺物として完全に排除する。
2. オブジェクトの凍結(Deep Freeze)の使い所を見極める:
どうしてもアプリケーション全体で不変性を強制したい設定値(Config)や定数オブジェクトには、必要に応じて `Object.freeze()` を組み合わせる。ただし、パフォーマンスクリティカルなループ内での過剰な `freeze` はオーバーヘッドになるため、静的な型定義(TypeScriptの `readonly`)とランタイムの `const` のコンビネーションで担保するのがベストプラクティスだ。
// アプリケーション設定の堅牢な宣言例
const APP_CONFIG = Object.freeze({
API_TIMEOUT: 5000,
MAX_RETRY_COUNT: 3,
FEATURES: Object.freeze({
ENABLE_NEW_DASHBOARD: true
})
});
結びにかえて
コードの品質は、プログラマが「どれだけ多くの例外やバグの可能性を頭の中でシミュレーションできるか」に比例する。しかし、優秀なアーキテクトは、人間の脳のメモリ容量(ワーキングメモリ)に頼らない。
`const` を極限まで多用し、データの変化をコードの静的構造に閉じ込めること。それこそが、複雑怪奇になりがちなフロントエンドの状態管理と非同期処理を制し、バグの起きない堅牢なプロダクションコードを構築唯一の王道なのである。