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

TypeScriptの型システムが隠す「配列の代入可能性」の罠

コードレビューをしていて、次のような関数シグネチャに遭遇したことはないでしょうか。

// 一見、何も問題なさそうなユーティリティ関数
function processItems(items: string[]): void {
// …
}

フロントエンド開発において、APIから取得したデータや、コンポーネントのプロパティとして渡された配列を処理する関数を書くとき、引数の型に `T[]`(あるいは `Array`)を指定するのは非常にありふれた光景です。

しかし、この何気ない `string[]` という記述こそが、大規模開発において予期せぬ副作用や、コンパイラが検知できないバグの温床となります。今回は、TypeScriptの型システムにおける「配列の変異性と代入可能性の罠」を解き明かし、堅牢なプロダクトコードを書くための実践的な設計論を伝授します。

—

なぜ `string[]` を引数に取るのは危険なのか?

TypeScriptの型システムにおいて、ミュータブルな配列型 `Array`(`T[]`)は、「その配列の内容が書き換えられる可能性がある」という契約を内包しています。

ここで、TypeScriptの構造的型付け(Structural Subtyping)と配列の不変性(Invariance / Covariance)に起因する、極めて重要な事実を思い出してください。

> 「読み取り専用の配列は、書き込み可能な配列を包含できるが、その逆は真ではない」

言葉だけでは抽象的なので、以下のコードを見てください。

const immutableConfig: ReadonlyArray = [‘A’, ‘B’, ‘C’];

function mutableAppender(arr: string[]): void {
arr.push(‘D’); // 引数で受け取った配列を破壊的に変更する
}

// 【コンパイルエラー】
// ReadonlyArray は string[] に代入できません。
mutableAppender(immutableConfig);

これは安全なエラーです。`immutableConfig` は「変更不可」と宣言されているにもかかわらず、それを `string[]`(変更可能)を期待する関数に渡そうとしたため、TypeScriptのコンパイラが未然に破壊的変更を防いでくれました。

問題なのは、この矢印の向きが逆転したときです。

const mutableTags: string[] = [‘react’, ‘typescript’];

// 引数に ReadonlyArray を要求する関数
function printTags(tags: ReadonlyArray): void {
// tags.push(‘vue’); <- 読み取り専用なのでコンパイルエラーになり、安全! tags.forEach(tag => console.log(tag));
}

// 【コンパイル成功】
// string[] は ReadonlyArray に代入可能。
printTags(mutableTags);

一見、これは「ミュータブルな配列をイミュータブルとして安全に読んでいるだけ」に見えるため問題ありません。しかし、関数内部の挙動や、呼び出し元の意図しない共有(Aliasing)が絡み合うと、話は一変します。

—

陥りがちな罠:型アサーションと意図しないミューテーション

次のコードは、実務の現場でよく見られる「一見きれいに書かれているが、バグを孕んだコンポーネントのロジック」です。

interface User {
id: number;
name: string;
}

// 状態管理やモックから提供される「変更不可」であるべきユーザーリスト
const INITIAL_USERS: ReadonlyArray = [
{ id: 1, name: ‘Alice’ },
{ id: 2, name: ‘Bob’ },
];

// ユーザーの表示順をソートして返すユーティリティ
function getSortedUsers(users: User[]): User[] {
// Array.prototype.sort() は「元の配列を破壊的に書き換える」メソッドです!
return users.sort((a, b) => a.name.localeCompare(b.name));
}

// 呼び出し
// コンパイルを通すために as User[] と型アサーション(ごまかし)を使ってしまう
const sorted = getSortedUsers(INITIAL_USERS as User[]);

console.log(INITIAL_USERS[0].name); // 「Alice」が返るはず…?

もし `users.sort()` が元の配列の参照をそのまま書き換える実装になっていた場合(実際、標準の `sort()` はインプレースソートです)、`INITIAL_USERS` というイミュータブルであるべき定数が外部の関数によって破壊的に書き換えられます。

`as User[]` という型アサーションは、TypeScriptの型安全性のバリアを開発者自らがブチ破る行為です。「コンパイラが怒るから、とりあえず嘘をついて通す」というコードを書いた瞬間から、TypeScriptの恩恵は消え去ります。

—

解決策:関数シグネチャには常に `ReadonlyArray`(または `readonly T[]`)を採用する

この問題を根本から解決し、保守性の高いコードベースを構築するための鉄則はただ一つです。

> 「関数が配列の要素を破壊的(Mutate)に変更しない限り、引数の型には必ず `ReadonlyArray`(あるいは構文糖衣である `readonly T[]`)を指定せよ」

先ほどのコードを、プロフェッショナルな設計にリファクタリングしましょう。

interface User {
readonly id: number;
readonly name: string;
}

const INITIAL_USERS: readonly User[] = [
{ id: 1, name: ‘Alice’ },
{ id: 2, name: ‘Bob’ },
];

// 【改善版】引数に readonly を指定し、非破壊的なメソッド(toSorted など)を使う
function getSortedUsers(users: readonly User[]): User[] {
// ES2023で導入された toSorted() は、元凶を汚さずに新しい配列を返します
// あるいは […users].sort(…) でシャローコピーを作ってからソートする
return […users].sort((a, b) => a.name.localeCompare(b.name));
}

// 型アサーションは一切不要。美しく安全にコンパイルが通る。
const sorted = getSortedUsers(INITIAL_USERS);

この設計にすることで、以下の強烈なメリットが生まれます。

1. 呼び出し元の自由度が爆発的に向上する: `readonly[]` も通常の `[]` も、どちらも `readonly` 引数に渡すことができるため、呼び出し側でわざわざキャスト(型アサーション)する必要がなくなります。
2. 関数内部での事故を防ぐ: うっかり `users.push()` や `users.sort()` を書いた瞬間にコンパイラが検知してくれます。
3. 副作用の局所化: コードの読み手は、その関数シグネチャを見ただけで「この関数は入力された配列を改変しない(Pure Functionに近い)」という保証を得られます。

—

実務で使える:堅牢なコンポーネント・API連携の設計パターン

最後に、フロントエンドの実務(ReactやAPIクライアント層)でそのまま使える、極限まで洗練されたコードパターンを提示します。

// — 1. ドメインモデルの定義 —
export type ProductId = string & { readonly __brand: unique symbol };

export interface Product {
readonly id: ProductId;
readonly title: string;
readonly price: number;
}

// — 2. APIクライアント層 —
// 非同期で外部から取得するデータも、当然イミュータブルとして扱う
async function fetchProducts(): Promise {
const response = await fetch(‘/api/products’);
const data: Product[] = await response.json();
return data; // 標準の配列は readonly にアップキャストされて返る
}

// — 3. 状態加工ロジック層(ビジネスロジック) —
// フィルタリングやマッピングを行う関数群
export function filterAffordableProducts(
products: readonly Product[],
maxPrice: number
): readonly Product[] {
// .filter() や .map() は元を汚さないため ReadonlyArray と相性抜群
return products.filter(product => product.price <= maxPrice); } // --- 4. UIコンポーネント層 (ReactのPropsなど) --- import React from 'react'; type ProductListProps = { // プロパティにも readonly を強制し、コンポーネント内での配列の誤操作をシャットアウト readonly products: readonly Product[]; readonly onSelect: (id: ProductId) => void;
};

export const ProductList: React.FC = ({ products, onSelect }) => {
return (

    {products.map(product => (

  • onSelect(product.id)}>
    {product.title} – ¥{product.price}
  • ))}

);
};

チーフアーキテクトからの総括

`ReadonlyArray`(`readonly T[]`)の活用は、単なる「型エラーを消すためのテクニック」ではありません。それは、アプリケーション全体のデータフローにおいて「どこでデータが変異しうるか(Mutation)」の境界線を明確にするための最高峰のアーキテクチャ設計です。

明日からのコードレビューでは、関数シグネチャの `T[]` が目に入ったら、こう問いかけてみてください。
「この関数は、本当にその配列を破壊する必要があるのか?」
もしNoであれば、躊躇なく `readonly T[]` へ書き換えてください。それだけで、あなたの書くTypeScriptコードの信頼性は一段上のステージへと引き上げられます。

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