コードレビューの現場から:`let` と `const` は「可変か不可変か」ではなく「状態のスコープ設計」である
コードレビューをしていて、未だに以下のようなコードに出くくすことがある。
// よく見かけるが、設計意図が不明瞭なコード
let userId = fetchUserId();
let permissions = getUserPermissions();
let userSettings = DEFAULT_SETTINGS;
// 300行下のロジック
if (someCondition) {
userSettings = overrideSettings(userSettings);
}
一見して「動くコード」ではある。だが、テクニカルリードの視点から言えば、これは「変数にどのようなライフサイクルと変遷を許容するのか」という設計思想を放棄した怠慢なコードだ。
JavaScript(ECMAScript 2015以降)において、`let` と `const` の使い分けは、単なる「後から値を変えるかどうか」の文法的なスイッチではない。それは、V8エンジンのメモリ管理、ガベージコレクションの最適化、そして何より「コードを読む人間に対する、この変数の意図・安全性の強いコントラクト(契約)」なのだ。
今回は、宣言的プログラミングにおける「再代入」の意図をコードで表現し、バグの起きない堅牢なフロントエンド/非同期処理アーキテクチャを構築するための極限の知見を伝授する。
—
1. V8エンジンの視点:なぜ `const` が優遇されるのか
V8エンジン(あるいは近代のJSランタイム)は、変数が再代入されるかどうかを静的解析レベルで常に監視している。
`const` で宣言された変数は、コンパイラに対して「このバインド(識別子とメモリ領域の紐付け)は不変である」という強いヒントを与える。
これにより、V8のJITコンパイラ(TurboFanなど)は、以下のような最適化の恩恵を受けやすくなる。
- インライン展開の最適化: 値が変化しないことが保証されているため、定数としてコードの深部に直接値を埋め込むインライン化(Constant Folding)が安全に行える。
- スコープ解析の高速化: デバッグ時やヒープスナップショット解析時において、不要な変数のライフサイクル追跡コストを削減できる。
つまり、可能な限り `const` を使うことは、パフォーマンスとメモリ効率の観点からも正解なのだ。
—
2. 実務で直面する「再代入の罠」と宣言的アプローチ
現代のフロントエンド開発において、状態(State)やDOMの操作、非同期API連携は複雑さを増している。ここで `let` を多用すると、どこで値が書き換わったのかを追う「状態の迷子(State Hell)」を引き起こす。
悪例:手続き型で `let` を汚染させた非同期データ処理
// 【アンチパターン】どこで値が書き換わるか追跡が困難
let processedData = null;
let hasError = false;
let errorMessage = ”;
async function loadDashboard(userId) {
try {
let rawData = await api.fetchUser(userId); // 再代入前提のlet
if (!rawData.isActive) {
hasError = true;
errorMessage = ‘User is inactive’;
return;
}
let enriched = transformData(rawData); // またlet
if (userHasVipRole(enriched)) {
enriched = applyVipPrivileges(enriched); // 再代入による状態のミューテーション
}
processedData = enriched;
} catch (err) {
hasError = true;
errorMessage = err.message;
}
}
このコードの問題点は、関数内のあちこちで変数が書き換わり、どの時点でどのような状態にあるのかを認知負荷高く追いかけなければならない点にある。エラーハンドリングもフラグ変数の書き換えに依存しており、保守性が非常に低い。
—
3. プロダクションコード:`const` とイミュータブル(不変)な関数型アプローチによる設計
では、テクニカルリードとして合格点を出す、`const` を徹底活用した宣言的コードを見てみよう。ここでは、変数の再代入を排除し、処理のパイプラインを明確に分離している。
/
- ユーザーダッシュボードのデータを安全にフェッチ・加工する
- すべての変数を const で宣言し、再代入を完全に排除した設計
/
async function loadDashboard(userId) {
try {
// 1. 不変なネットワークリクエストの実行
const rawData = await api.fetchUser(userId);
// ガード節による早期リターン(ネストとletの排除)
if (!rawData.isActive) {
return createErrorState(‘User is inactive’);
}
// 2. データの加工と条件分岐を「式(Expression)」として表現
const baseEnriched = transformData(rawData);
const finalData = userHasVipRole(baseEnriched)
? applyVipPrivileges(baseEnriched)
: baseEnriched;
// 3. 成功状態のオブジェクトを即時返却(状態の外部汚染を防ぐ)
return {
success: true,
data: finalData,
error: null,
};
} catch (err) {
// 異常系も純粋にデータとして返す
return createErrorState(err.message);
}
}
/
- エラー状態を生成するヘルパー(イミュータブルなオブジェクトを返す)
/
const createErrorState = (message) => ({
success: false,
data: null,
error: message,
});
この設計が優れている理由
1. すべての変数が `const`: 宣言された変数は、そのスコープ内において「二度と値が変わらない」ことが保証されている。脳内の変数トラッキングメモリを消費しない。
2. 三項演算子と関数の組み合わせによる「文(Statement)」から「式(Expression)」への昇華: `if` 文の中で `let` を書き換えるのではなく、条件に応じた結果を一度の代入(`const finalData = …`)で決定している。これにより、スコープの生存期間が極限まで短くなる。
3. 副作用(Side Effect)の遮断: 外部スコープの `processedData` を汚染せず、純粋な関数の返り値としてデータを伝搬させるため、予測可能性が劇的に向上する。
—
4. DOM操作・配列処理におけるパフォーマンスと `const` の規約
フロントエンドのパフォーマンスチューニングにおいて、DOM操作や大量の配列処理を行う際も、変数の宣言スコープは極めて重要だ。
配列のイミュータブル操作(`const` とメソッドチェーン)
`let` を使って `for` ループで配列をミューテートするコードは、モダンなJS環境では非効率であり、バグの温床になる。
// 【NG】let と forループによるミュータブルな配列操作
let activeItems = [];
for (let i = 0; i < rawItems.length; i++) {
if (rawItems[i].status === 'active') {
let formatted = formatItem(rawItems[i]);
activeItems.push(formatted);
}
}
// 【OK】const と宣言的な配列メソッド(map / filter)の組み合わせ
// ガベージコレクションとJITの最適化を引き出しやすい
const activeItems = rawItems
.filter(item => item.status === ‘active’)
.map(item => formatItem(item));
`const activeItems` と宣言することで、この配列の参照先(Reference)が変わらないことが保証される。内部の要素の加工は `map` や `filter` といった高階関数に委譲し、各ステップを `const` で定数化することで、コードの可読性とモジュール性が跳ね上がる。
—
5. 例外:それでも `let` を使うべき唯一の正当な理由
もちろん、すべての変数を `const` にできるわけではない。`let` を使うことが許される、あるいは使わなければならない「例外的な領域」も存在する。
1. ループカウンター(`for (let i = 0; i < length; i++)` の `i`):
- ただし、現代のフロントエンドでは前述の通り `map`/`filter`/`reduce` や `for…of` が推奨されるため、ネイティブの `for` ループを書く機会自体が減っている。
2. アルゴリズム的な状態の累積(リデューサー内部や数学的計算、再試行回数のトラッキングなど):
- リトライロジックなどで、どうしてもインクリメントが必要な場合。
// 再試行ロジックなど、厳密に状態が変化せざるを得ない局所的スコープ
async function fetchWithRetry(url, maxRetries = 3) {
let attempt = 0; // 状態変化が必要なため例外的にletを使用
while (attempt < maxRetries) {
try {
return await api.get(url);
} catch (err) {
attempt++;
if (attempt >= maxRetries) throw err;
await sleep(1000 attempt);
}
}
}
このような「局所的かつ明確にミュータブルであることが要件である場合」にのみ `let` を限定し、それ以外はすべて `const` で縛る。この規約をチーム全体で徹底することが、レガシー化しない堅牢なコードベースを作る唯一の道である。
—
結びにかえて:コードの美しさは設計の硬さに宿る
`let` と `const` の使い分けは、Linterのルール(ESLintの `prefer-const` など)に従うだけの形式的な作業ではない。
- 「私はこの変数を不変として扱い、予期せぬバグの温床を断つ」という強い意志の表明であり、
- 「V8エンジンに最適化のヒントを与え、ランタイムのパフォーマンスを最大化する」というエンジニアリングの極みである。
明日からのコードレビューでは、何気なく打たれている `let` に目を光らせてほしい。「なぜここは `const` にできないのか?」その問いを投げかけられるようになった時、あなたの書くコードは確実に、世界最高峰のレベルへと到達しているはずだ。