【実務・中級編】 ReadonlyArrayによる配列の不変性 – TypeScript実践ガイド

こんにちは。フロントエンドチームのシニアアーキテクトだ。
君たちが日々、コンポーネントの状態管理やAPIからのデータフェッチで頭を悩ませているのはよく知っている。

さて、今日はTypeScriptの「型」の話題、それも実務で最もやりがちな「うっかりミューテーション(破壊的変更)」を防ぐための強力な武器、`ReadonlyArray`(およびその糖衣構文である `readonly T[]`)について話をしよう。

「配列なんて `const` で宣言しておけば書き換えられないでしょ?」
もし君がそう思っているなら、今すぐその認識をアップデートしてほしい。`const` が守るのは「変数バインディング(再代入)」であって、「データの中身」ではないんだからね。

今回は、なぜ実務において配列の不変性(Immutability)が神聖不可侵なのか、そしてTypeScriptがどうやってそれをコンパイル時に担保してくれるのか、ブラウザの裏側の挙動も含めて叩き込んでいこう。

—

なぜ `const` だけでは不十分なのか?

まずは、JavaScript(そしてそれを内包するTypeScript)の基本に立ち返ろう。
君たちが普段何気なく書いているこのコードを見てほしい。

const numbers = [1, 2, 3];
numbers.push(4); // エラーにならない!

「あれ? `const` なのに要素が追加できたぞ?」と慌てた経験はないかい?
ブラウザのメモリ上(ヒープ領域)において、配列は「ミュータブル(変更可能)なオブジェクト」として生成される。`const` は単に `numbers` という変数に別の配列オブジェクトを再代入(`numbers = […]`)することを禁止しているだけで、配列オブジェクトそのものの状態が変化することは止められない。

これが実務でどういう悲劇を生むか。
例えば、ReactのStateやVueのReactiveなデータを、どこかのユーティリティ関数にこっそり渡したとする。その関数が知らず知らずのうちに `sort()` や `pop()` なんていう破壊的メソッドを呼び出していたら……?
画面が再レンダリングされなかったり、予期せぬタイミングでバグが起きて、何時間もデバッグに費やすハメになる。これ、現場あるあるだよね。

`ReadonlyArray` の正体と、ブラウザの裏側の話

ここで登場するのが `ReadonlyArray` だ。
TypeScriptにおいて、この型は「配列の要素を変更する可能性のあるメソッド(`push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse` など)の型定義をすべて剥ぎ取ったインターフェース」として定義されている。

実行時には「ただの配列」になる

ここで重要なポイントを一つ。
TypeScriptの型システムは、コードをJavaScriptにコンパイルする際にすべて消え去る。つまり、ブラウザが実行しているJavaScriptのランタイム上では、`ReadonlyArray` もただの通常の `Array` だ。ブラウザのV8エンジンなどのメモリ上で、特別な保護領域が作られているわけではない。

「じゃあ、実行時に書き換えられちゃうリスクはあるのでは?」
その通り。JavaScriptのランタイムレベルでは防げない。しかし、TypeScriptの静的型チェックという「強力なガードレール」を挟むことで、開発者が意図しないミューテーションをコードを書いている瞬間に検知できる。これが `ReadonlyArray`(および `readonly T[]`)の真価なんだ。

現場で使える!実践コードとベストプラクティス

百聞は一見にしかずだ。実務でどう使うべきか、具体的なコードを見ていこう。
以下のコードは、そのままコピーして君のエディタで動かせる。

/

  • ユーザー情報の型定義

/
type User = {
id: number;
name: string;
};

// 1. 宣言時の書き方
// `ReadonlyArray` の代わりに、短縮形の `readonly User[]` が一般的によく使われる
const initialUsers: readonly User[] = [
{ id: 1, name: ‘Alice’ },
{ id: 2, name: ‘Bob’ },
];

// 【コンパイルエラーの例】
// initialUsers.push({ id: 3, name: ‘Charlie’ });
// Error: Property ‘push’ does not exist on type ‘readonly User[]’. (型 ‘readonly User[]’ にはプロパティ ‘push’ がありません)

/

  • 2. 関数の引数に「変更しないこと」を強制する
  • 引数を readonly にすることで、この関数内で配列が汚染されないことが保証される

/
function printUserNames(users: readonly User[]): void {
// map や filter などの「非破壊的(新しい配列を返す)メソッド」は安全に使える
const names = users.map(user => user.name);
console.log(‘ユーザー一覧:’, names.join(‘, ‘));

// 万が一、この中でうっかりusers.sort()などを書こうものなら、即座にTSが赤く怒ってくれる
}

printUserNames(initialUsers);

/

  • 3. 不変性を保ったまま配列を更新する(イミュータブルな操作)
  • 新しい要素を追加したいときは、スプレッド構文 (…) を使って新しい配列を生み出す

/
function addUser(users: readonly User[], newUser: User): readonly User[] {
// 元の users は汚染せず、新しい配列を返す
return […users, newUser];
}

const updatedUsers = addUser(initialUsers, { id: 3, name: ‘Charlie’ });
console.log(‘更新後:’, updatedUsers);

Tips: `readonly` と `as const` の違い

よくある勘違いとして、`as const`(constアサーション)との混同がある。

  • `readonly T[]`:配列の要素の追加・削除・置換を禁止する。要素のプロパティ自体(例: `user.name`)は、特に `readonly` をつけていなければ書き換え可能(シャローな不変性)。
  • `as const`:配列そのものだけでなく、中のプリミティブな値まで完全にイミュータブル(リテラル型かつdeep readonly)にする。

APIのレスポンスや、設定値の定数定義などでは `as const` を使うのが定石だが、通常のコンポーネント間で受け渡すリストデータなどには、今回紹介した `readonly T[]` が最も手軽で効果的だ。

—

シニアからのまとめ

フロントエンドの規模が大きくなればなるほど、バグの温床になるのは「意図しないデータの書き換え(副作用)」だ。
「この関数に配列を渡したら、中身が勝手に書き換わっていた……」なんていう悪夢を、TypeScriptの型定義一つで未然に防げるのだから、使わない手はない。

今日から君の書くコードの関数の引数や、ステートの型定義で、配列を見かけたらまずは `readonly` をつける癖をつけよう。チームメンバーからも「お、分かってるな」一目置かれるはずだ。

それじゃあ、今日もセキュアで美しいコードを書いていこうぜ!

コメント

タイトルとURLをコピーしました