【テクニカル・上級編】関数シグネチャにおける「ReadonlyArray」と「Array」の代入可能性の罠 – TypeScript コア・型システムの基礎解析バイブル

TypeScriptを掌握する極限の知見:`ReadonlyArray` と `Array` の代入可能性が孕む型システムの罠

ランタイムエンジンの仕様策定や大規模システムの最前線に身を置く者であれば、TypeScriptの型システムが持つ「構造的型付け(Structural Subtyping)」の美しさと、それがもたらす実務上の残酷なパラドックスを幾度となく目撃してきたはずだ。

特に、配列(Array)と不変配列(ReadonlyArray)の境界領域における代入可能性の挙動は、コンパイル時の静的安全性と実行時のメモリ安全性との間で、最も見落とされがちな「地雷原」の一つである。

今回は、関数シグネチャにおける `ReadonlyArray` と `Array` の関係性を徹底的に解剖し、なぜ通常の `Array` を安易に受け入れる設計がシステムの防壁を容易に突破させるのか、その低レイヤの型評価メカニズムと共に対策を紐解く。

—

1. 共変性(Covariance)の罠:なぜ `Array` は `ReadonlyArray` に代入できるのか

TypeScriptの型システムにおいて、配列型 `Array` はその要素型 `T` に対して共変(Covariant)である。これは直感的にも理解しやすい。`string` の配列は、広義には `string | number` の配列として扱えるし、より一般的には、より具体的な型から抽象的な型への代入が許容される。

しかし、ここで見落としてはならないのは、`Array` は `ReadonlyArray` のサブタイプ(subtype)であるという事実だ。

let mutableNumbers: number[] = [1, 2, 3];
let readonlyNumbers: ReadonlyArray = mutableNumbers; // 正常にコンパイルが通る

「書き込み可能な配列を、読み込み専用の配列として扱うのだから安全ではないか」——そう考えた瞬間、あなたはアーキテクトとしての敗北を喫する。これは安全性の向上ではなく、「エイリアシング(Aliasing)を通じた不変性の破壊」への扉を開く行為に他ならない。

コンパイラの裏をかくエイリアスの恐怖

以下のコードを見てほしい。

function processSafely(data: ReadonlyArray): void {
// data.push(4); // コンパイルエラー: Property ‘push’ does not exist on type ‘ReadonlyArray‘.
console.log(data.length);
}

const original: number[] = [1, 2, 3];
processSafely(original);

// 関数スコープの外から、同じ参照を持つ配列がミューテートされる
original.push(4);

`processSafely` の内部では `ReadonlyArray` として厳重に保護されているはずの配列が、外部のミュータブルな参照 (`original`) を介して書き換えられた。JavaScriptのランタイムにおいて、配列はプリミティブではなく参照型(Reference Type)としてヒープ上に存在するため、型システムが「読み取り専用」と強制しているのはあくまで「ある特定の見え方(View)」に過ぎず、実メモリ上のデータ構造そのものが凍結されているわけではない。

—

2. 関数シグネチャにおける致命的なアンチパターン

大規模なイベント駆動型アーキテクチャや、非同期ストリームを処理するパイプラインを設計する際、次のような関数シグネチャを書いていないだろうか。

// アンチパターン:呼び出し側に不要なミュータビリティの責任を押し付ける
interface EventPipeline {
dispatch(events: Array): void;
}

このシグネチャは、呼び出し側に対して「私はあなたから渡された配列を破壊的に変更するかもしれないし、しないかもしれない」という曖昧な契約を結ばせている。結果として、呼び出し側は防御的に `[…events]` のようなシャローコピー(Spread Syntax)を散財させ、不要なガベージコレクション(GC)の負荷をヒープ上に発生させることになる。

イベントループの厳密なキュー消費メカニズムにおいて、この不要なアロケーションの積み重ねは、マイクロタスクキューの消化遅延を引き起こし、高ス負荷時におけるレイテンシスパイクの主因となる。

—

3. 解決策:真の不変性を保証するシグネチャ設計

この問題を根底から解決するためには、関数シグネチャの引数を常に `ReadonlyArray`(あるいはその糖衣構文である `readonly T[]`)へと昇格させることだ。

interface OptimizedEventPipeline {
// 呼び出し側に「この配列は破壊しない」という厳格な契約を強要する
dispatch(events: readonly DomainEvent[]): void;
}

このシグネチャに変更することで、以下の3つの極限的なメリットがもたらされる。

1. ゼロコストの型安全保障
呼び出し側は、ミュータブルな `Array` も、完全な不変配列も、一切のキャストやコピーなしでそのまま関数に渡すことができる。コンパイラは構造的型付けに基づき、安全なアップキャストを静的に保証する。
2. ランタイムアロケーションの抑制
無駄な配列の複製(Spreadコピー)を行う必要がなくなるため、V8エンジンなどのJSエンジンにおけるヒープ領域の断片化を防ぎ、GCの休止時間(Stop-The-World)を最小化する。
3. 意図しない副作用の完全排除
関数内部で `push`, `pop`, `splice` などのミュータブルなメソッドにアクセスすることが型レベルでコンパイルエラーになるため、純粋関数(Pure Function)としての性質が強制される。

—

4. 深層:ディープ・リードオンリーへの挑戦

表面的な配列の読み取り専用化だけでは、防壁としては不十分な場合がある。配列の要素自体がオブジェクトである場合、その内部プロパティは依然として書き換え可能だからだ。

interface Task {
id: string;
payload: {
status: ‘PENDING’ | ‘DONE’;
};
}

function executeTasks(tasks: readonly Task[]): void {
// コンパイルエラーにならない!配列自体はreadonlyだが、要素のオブジェクトはミュータブル
tasks[0].payload.status = ‘DONE’;
}

この「シャローな不変性」の限界を突破するには、TypeScriptの条件付き型(Conditional Types)と再帰(Recursion)を駆使したユーティリティ型を構築する必要がある。

// 再帰的にすべてのプロパティと配列をReadonly化する極限の型定義
type DeepReadonly = T extends (infer R)[]
? ReadonlyArray>
: T extends Function
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly }
: T;

function executeTasksStrict(tasks: DeepReadonly): void {
// コンパイルエラー: Cannot assign to ‘status’ because it is a read-only property.
// tasks[0].payload.status = ‘DONE’;
}

この `DeepReadonly` を関数シグネチャに適用することで、コンパイラはデータ構造の末端に至るまでの不変性を静的に担保し、実行時における予期せぬ状態変異(State Mutation)の脆弱性を完全に遮断する。

—

5. アーキテクトからの提言

TypeScriptの型システムは、単なる「補完のためのツール」ではない。それは、ランタイムの動作を静的に支配し、メモリ上の安全性をコンパイルタイムで証明するための「論理的防壁」である。

`Array` と `ReadonlyArray` の代入可能性に潜む罠を理解し、関数シグネチャを適切にデザインすることは、単なるコーディング規約の域を超え、システム全体のパフォーマンスと堅牢性を決定づける極めて重要なエンジニアリング行為なのだ。

今日のビルドから、あなたのコードベースにあるすべての配列引数を見直せ。`readonly` の付与されていない配列受け入れ口は、すべてセキュリティホールであり、パフォーマンスの澱(おり)であると心得よ。

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