配列の「見かけ上の不変性」に殺されないために:`ReadonlyArray
こんにちは。日夜、V8エンジンの機嫌を取りながら、巨大なフロントエンド・アーキテクチャの要塞を築いているチーフアーキテクトだ。
お前たちのコードベースを覗かせてもらうと、相変わらず `Array
TypeScriptの型システムにおいて、`any` や `unknown` の危険性については散々語り尽くされてきた。だが、「配列の不変性(Immutability)」という、モダンWebアプリケーションのパフォーマンスと堅牢性を左右する最重要課題について、どれだけのエンジニアが型レベルで本気で向き合っているだろうか?
今回は、ただの「書き換え禁止の糖衣構文」ではない、`ReadonlyArray
—
1. なぜ `Array` はフロントエンドの隠れた爆弾なのか?
JavaScriptの `Array` は、本質的に「可変(Mutable)」なオブジェクトだ。`push`、`pop`、`sort`、さらには直接インデックスを指定した代入(`arr[0] = newValue`)により、メモリ上のデータがその場で書き換えられる。
これが何を引き起こすか?
関数に配列を渡したつもりが、その関数内で破壊的メソッドを叩かれ、呼び出し元のデータが書き換わる(いわゆるサイドエフェクトの漏洩)。TypeScriptを使っていながら、ランタイムで「あれ?なんでこのデータが変わってるんだ?」と `console.log` を大量に埋め込むハメになる。
ここで多くの開発者がやりがちな間違いがこれだ。
// ❌ よくある「気休め」のconst
const numbers = [1, 2, 3];
numbers.push(4); // 普通に通る! constは「再代入不可」であって「中身の不変性」を保証しない
`const` は変数のバインドを固定するだけで、オブジェクトや配列の内部構造がイミュータブルになるわけではない。このJavaScriptの仕様の罠から私たちを救い出すのが、TypeScriptの `ReadonlyArray
—
2. `ReadonlyArray` の正体と、型安全性のメカニズム
TypeScriptのコンパイラ(tsc)にとって、`ReadonlyArray
内部の型定義を覗いたことがあるなら知っているはずだが、`ReadonlyArray
// ⭕️ 正しく ReadonlyArray を活用した例
const processUserIds = (ids: ReadonlyArray
// コンパイルエラー: Property ‘push’ does not exist on type ‘ReadonlyArray
// ids.push(999);
// 以下のような非破壊的な操作は問題なく型を通る
const extendedIds = […ids, 999];
return extendedIds;
};
const originalIds: readonly number[] = [1, 2, 3]; // ReadonlyArray
processUserIds(originalIds);
ここで注目してほしいのは、「通常の `Array
const mutableArray: number[] = [1, 2, 3];
// 代入可能:Mutableな配列は、Readonlyとして扱う分には何も問題がないため
const readonlyArray: ReadonlyArray
// 逆は不可(ReadonlyをMutableな変数には入れられない)
// const backToMutable: number[] = readonlyArray; // エラー
この「読み取り専用の契約(Contract)」を関数シグネチャの引数に強制することで、「この関数は絶対に渡された配列を破壊しない」という強い保証をアーキテクチャ全体に波及させることができる。
—
3. パフォーマンスとメモリ効率のジレンマ:コピーのコスト
「じゃあ、すべての配列を常にイミュータブルにするために、毎回スプレッド構文(`[…arr]`)でクローンを作ればいいのか?」
待ってほしい。チーフアーキテクトとして、ここでお前たちに警鐘を鳴らさなければならない。
安易な配列のクローン作成は、V8エンジンのガベージコレクター(GC)に過大な負荷をかけ、最悪の場合、フレームレートの低下(Jank)を引き起こす。
数万件の要素を持つ巨大な配列や、高速なループ内で毎回 `[…arr]` を実行するとどうなるか?
メモリ上に不要なオブジェクトのコピーが爆誕し、世代別GC(Generational GC)のマイナーGC/メジャーGCの頻度が跳ね上がる。UIスレッドがGCの停止時間(Stop-the-worldに近い挙動)に足を引っ張られ、スムーズなスクロールやアニメーションがカクつく原因になるのだ。
解決策:構造的共有(Structural Sharing)とイミュータブルライブラリの選択
ここで `ReadonlyArray
`ReadonlyArray
データ構造そのものをコピーせず、既存のメモリ領域を指したまま安全に扱える。つまり、実行時のメモリオーバヘッドをゼロにしつつ、型安全な不変性を担保できるという、パフォーマンスと堅牢性の両立を実現する最強の武器なのだ。
さらに、本格的なイミュータビリティが必要な大規模な状態管理(ステートツリーなど)においては、`Immutable.js` や Immer のような、構造的共有(変更された部分の参照だけを新しくし、不変な部分はメモリを共有するテクニック)を提供するライブラリと `ReadonlyArray
—
4. 非同期処理の競合(Race Condition)と `ReadonlyArray` の防衛力
非同期処理が絡む複雑なWebアプリケーションにおいて、バグの温床となるのが「非同期の途中で参照しているデータが書き換わる」という現象だ。
例えば、APIリクエストを投げ、そのレスポンスを待つ間にユーザーが画面を操作して、元の配列データがミューテートされたとする。Promiseが解決したとき、手元にあるデータはすでに「古くて汚染されたデータ」に変貌している。
class DataProcessor {
private cache: readonly string[] = [];
// 非同期でデータを取得し、キャッシュを更新する
async fetchAndCache(url: string): Promise
const response = await fetch(url);
const data: string[] = await response.json();
// 内部キャッシュには必ず凍結された型として保持する
// これにより、クラスの外部や内部の別の非同期処理から誤って書き換えられるのを防ぐ
this.cache = data;
}
getCache(): ReadonlyArray
return this.cache;
}
}
このように、状態の保持に `ReadonlyArray
—
5. Reactレンダリング最適化と `ReadonlyArray`
Reactのパフォーマンスチューニングにおいて、`React.memo` や `useMemo`、`useCallback` はお馴染みだろう。これらは「参照の同一性(Reference Equality)」に基づいて再レンダリングをスキップする。
もし、コンポーネントのプロパティ(Props)として渡される配列が `Array
Propsの型定義を `ReadonlyArray
type ItemListProps = {
// 読み取り専用配列であることを明示し、子コンポーネントでのミューテーションをコンパイルエラーにする
items: ReadonlyArray
};
export const ItemList: React.FC
console.log(‘ItemList rendered’);
return (
-
{items.map((item, index) => (
- {item}
))}
);
});
—
6. まとめ:アーキテクトとしてコードベースに「規律」を刻め
TypeScriptの `ReadonlyArray
1. 副作用の排除: 関数がデータを破壊しないという「契約」を型で強制する。
2. パフォーマンスの最適化: 無駄な配列の複製(GC負荷)を避けつつ、安全性を確保する。
3. 非同期・並行処理の堅牢性: 予期せぬデータの書き換えによる競合バグを根絶する。
4. フレームワークとの親和性: React等のレンダリング最適化の信頼性を高める。
凡百のプログラマーは動くコードを書く。だが、一流のアーキテクチャ設計者は「間違ったコードが書けない構造(Fail-safe architecture)」を作る。
明日から、お前のプロジェクトのすべての関数の引数と戻り値、そしてステート定義にある `Array
コードベースの美しさと堅牢性は、そこから始まる。

コメント