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

こんにちは!TypeScriptの型システムの世界へようこそ。
フロントエンドからバックエンドまで、TypeScriptであらゆるシステムを構築していると、「あれっ、これだけシンプルな配列の受け渡しなのに、なぜか型エラーになる……?」という謎の壁にぶつかることがよくありますよね。

他の言語(JavaやC#、あるいはJavaScriptそのもの)からやってきた開発者ほど、この罠に綺麗にハマりがちです。

今回は、関数シグネチャにおける `ReadonlyArray` と `Array` の代入可能性の罠 について、コンパイラの裏側の挙動まで含めて、優しく、かつ深く紐解いていきましょう。
ここをクリアすれば、あなたのTypeScriptの型に対する解像度は一段と跳ね上がりますよ。バッチリマスターしていきましょう!

—

1. 最初に結論:「読み取り専用」と「書き込み可能」の関係

まずは、TypeScriptの型システムが配列をどう見ているのか、頭の中にイメージ図を作ってみましょう。

[ 通常の配列: Array ]
┗ 読み取りができる + 書き換え(push や代入)もできる!

▲ (代入できない!の罠)
│
[ 読み取り専用配列: ReadonlyArray ]
┗ 読み取りだけができる(書き換えメソッドは型レベルで隠される)

直感的には、「書き換えできないもの(Readonly)のほうが安全なんだから、書き換えができる場所にも渡せるはず(代入できるはず)」と思いませんか?

実は、TypeScriptの型システム(そして多くのモダンな言語の型安全性)では、これが「逆」になります。
ここが、開発者が最初に踏み抜きやすい最初の罠なんです。

—

2. なぜエラーになるのか? 実際のコードで見てみよう

次のような、受け取ったタスクのリストを表示しつつ、ログ用に内部で配列をいじる関数を考えてみます。

// 普通の配列を受け取る関数
function printTasks(tasks: string[]) {
console.log(“タスク一覧:”, tasks.join(“, “));
}

// 読み取り専用の配列を用意した
const readonlyTasks: readonly string[] = [‘設計’, ‘実装’, ‘テスト’];

// さあ、これを渡してみよう!
printTasks(readonlyTasks);
// ❌ ここでTypeScriptコンパイラが赤く波線を出して怒り出します!

コンパイラは何を怒っているのか?

このとき、TypeScriptコンパイラは次のようなエラーを出します。
> 型 ‘readonly string[]’ をパラメータ ‘string[]’ に割り当てることはできません。
> 型 ‘readonly string[]’ は ‘string[]’ 型の読み取り専用プロパティですが、変更可能です。

「えっ、中身を読み取るだけで、`printTasks` の中で `tasks` を書き換えてないのに、なんでダメなの?」と思いますよね。
ここに、型安全性の本質が隠されています。

もし、`readonly string[]`(書き換え不可)を、`string[]`(書き換え可能)を期待する関数に渡せてしまったとしましょう。

function breakTheRule(tasks: string[]) {
// 渡された側は「何してもいい(書き換え自由な)配列だ」と思っている
tasks.push(“勝手に追加しちゃえ!”);
}

const myReadonlyTasks: readonly string[] = [‘A’, ‘B’];

//もしこれが許されると…
breakTheRule(myReadonlyTasks);
// 原本であるはずの myReadonlyTasks の中身が勝手書き換えられてしまう!

TypeScriptは、「書き換えができるかもしれない配列(Array)」のふりをして、実は「絶対に書き換えられたくない配列(ReadonlyArray)」を渡すことを、バグの温床(副作用の漏洩)とみなして厳しく禁止しているのです。

—

3. 解決策:関数側が「読み取り専用」を受け入れるようにする

では、安全かつ柔軟にこの問題を解決するにはどうすればよいでしょうか?
答えは簡単です。関数が受け取る引数の型を、`ReadonlyArray`(またはそのショートハンド)に引き下げてあげる(広くしてあげる)のです。

TypeScriptには、`ReadonlyArray` の他に、よりスマートに書ける構文糖衣(シュガーシンタックス)が用意されています。

改善されたコード

// 改善版:この関数は「読み取りしかしないよ」と宣言する
function printTasksSafe(tasks: readonly string[] / または ReadonlyArray /) {
console.log(“タスク一覧:”, tasks.join(“, “));

// 試しにここで書き換えようとしてみる
// tasks.push(“おっと”);
// ❌ コンパイラが「そんなメソッドないよ!」とここで防いでくれる!
}

const readonlyTasks: readonly string[] = [‘設計’, ‘実装’, ‘テスト’];

// 完璧にコンパイルを通過します!
printTasksSafe(readonlyTasks);

// おまけ:普通の配列(書き換え可能)を渡す分には…?
const normalTasks: string[] = [‘要件定義’];
printTasksSafe(normalTasks);
// ⭕️ これは通ります!
// 「書き換え可能な配列」は、「読み取り専用として扱う分には安全」だからです。

おや? 最後の行に注目してください。

  • `readonly` なものを、通常の `Array` に入れるのは NG
  • 通常の `Array` を、`readonly` な引数に渡すのは OK

この矢印の向き(代入可能性の方向)を覚えるのが、TypeScriptの型をマスターする近道です。

—

4. 実務で役立つ!型エイリアスとas constの合わせ技

実務の現場では、APIから返ってきたレスポンスや、設定ファイルのオブジェクトなどを変更不可(イミュータブル)に保ちたい場面が多々あります。

そんなときは `as const` (constアサーション)と `ReadonlyArray` をセットで使いこなしましょう。

// アプリケーションの画面状態を定義
const allowedScreens = [‘Home’, ‘Profile’, ‘Settings’] as const;
// 型は readonly [“Home”, “Profile”, “Settings”] になる

// 画面遷移を処理する関数
function navigate(screen: typeof allowedScreens[number]) {
console.log(`${screen} へ遷移します`);
}

// allowedScreens の要素だけを型安全に受け取れる
navigate(‘Home’); // ⭕️ OK
// navigate(‘Admin’); ❌ 型エラー(定義されていない画面)

このように、配列の定義を `as const` で固め、関数側で適切に `readonly`(あるいはその派生型)を受け入れるシグネチャにしておくと、予期せぬバグが入り込む余地をコンパイル時に完全に消し去ることができます。

—

まとめ:今日の学びを振り返る

  • 罠の正体: 「書き換え可能な配列」を要求する場所に、「読み取り専用配列」は渡せない(副作用の漏洩を防ぐため)。
  • 方向性: 「読み取り専用配列」を要求する関数には、通常の配列も渡せる(読み取りだけなら安全だから)。
  • ベストプラクティス: 関数内で配列の要素を破壊的に変更(`push`, `pop`, `splice` など)しないのであれば、引数の型は積極的に `readonly T[]` や `ReadonlyArray` にしよう。

ここをクリアできると、不必要な型アサーション(`as string[]` のようなごまかし)を書く必要がなくなっていき、TypeScriptが本来持っている強靭な型安全性の恩恵をフルに受けられるようになります。

「なぜこのエラーが出るのか?」の裏側にあるコンパイラの意図が分かると、コードを書くのがもっと楽しくなりますよね。
あなたのTypeScriptライフが、より快適で堅牢なものになりますように。それではまた!

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