【入門編】関数シグネチャにおける「Readonly」と「Mutable」の混在を防ぐ型設計 – TypeScript コア・型システムの基礎解析バイブル

こんにちは!TypeScriptの型システムの世界へようこそ。
日々、フロントエンドからバックエンドまでコードを書いていると、「関数に渡したオブジェクトを、うっかり内部で書き換えてしまってバグが起きた……」なんて悲劇に遭遇したことはありませんか?

他の言語からTypeScriptに入ってきた方だと、「定数(`const`)で宣言しておけば安全なんでしょ?」と思いがちですが、実はそれだけでは不十分なケースがたくさんあるんです。

今回は、関数シグネチャ(関数の設計図)のレベルで「ここから先は絶対にデータを書き換えさせない!」とイミュータブル(不変)を強制する型設計の極意を、優しく、そして本質的に解説していきますね。

ここをクリアすれば、TypeScriptの型システムの本質がグッと見えてきますよ。一緒にバッチリマスターしていきましょう!

—

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

まずは、よくある「落とし穴」から見てみましょう。
JavaScriptやTypeScriptでは、変数を `const` で宣言しても、オブジェクトの中身(プロパティ)の書き換えまでは禁止してくれません。

// ユーザーのデータを表す型
type User = {
name: string;
age: number;
};

// constで宣言したから安心……?
const user: User = {
name: ‘Taro’,
age: 25,
};

// これはエラーになる(再代入はできない)
// user = { name: ‘Jiro’, age: 30 };

// しかし!プロパティの書き換えはすり抜けてしまう!
user.age = 26; // エラーにならない!

`const` は「変数バインディング(箱のラベル)」を守るものあって、「オブジェクトの構造(箱の中身)」を守るものではないんです。

これを関数に渡すとき、「この関数はデータを読むだけで、変更はしないはず」と思っていても、JavaScriptの参照渡しのせいで、関数内でうっかり `user.age = 99` なんて書けてしまいます。これがバグの温床になりますよね。

—

2. 関数の入り口で守る:`Readonly` の基本

そこで登場するのが、TypeScriptが標準で用意してくれているユーティリティ型 `Readonly` です。

これを使うと、指定したオブジェクトのすべてのプロパティを「読み取り専用(Readonly)」に変換してくれます。関数が受け取る引数の型にこれをつけてみましょう。

type User = {
name: string;
age: number;
};

// 関数が受け取る引数を Readonly にする
function printUserSummary(user: Readonly) {
console.log(`${user.name}さんは ${user.age} 歳です。`);

// 【コンパイルエラー!】
// 読み取り専用なので、書き換えようとするとTypeScriptが怒ってくれます
// user.age = 30;
}

const myUser: User = { name: ‘Hanako’, age: 22 };
printUserSummary(myUser);

このように、関数シグネチャの段階で `Readonly` を指定しておけば、関数の中で誤ってデータを破壊するコードを書いた瞬間、実行するまでもなくコンパイルエラーとして検知できます。これがTypeScriptの最大の強みです!

—

3. 陥りやすい罠:部分的なミュータブル(可変)の混在

さて、ここからが少し高度で、現場で本当によくある「混在」のトラブルです。

実際のアプリケーションでは、「オブジェクト全体の大部分は読み取り専用にしたいけれど、特定のプロパティだけは更新を許可したい(またはオプショナルにしたい)」というケースがよくあります。

例えば、次のようなコードを書いてしまったとしましょう。

type Config = {
readonly endpoint: string;
readonly timeout: number;
retries: number; // ここだけは書き換え可能にしたい
};

こういう設計のとき、関数側で次のようにシグネチャを定義してしまうと、意図しない型安全性の穴が生まれます。

// ❌ 良くない例:曖昧なシグネチャ
function updateConfig(config: Config) {
config.retries = 5; // これはOK(書き換え可能だから)

// あれ? endpoint も書き換えられてしまう……!
// (もし Config 型の定義側で readonly を付け忘れていた場合など)
}

「Mutable(可変)」を明示的にコントロールする

逆に、ベースが `Readonly` なオブジェクトを受け取りつつ、特定の操作のために一時的なMutableなオブジェクトを扱いたい場合はどうすればよいでしょうか?

もし関数内でどうしても一部を書き換えたい、あるいは特定のミュータブルな処理を許容する関数を作りたい場合は、ユーティリティ型 `Mutable`(標準ではないので独自に定義するか、部分的に型を剥がす)という発想が必要になります。

ですが、基本方針として覚えておいてほしいベストプラクティスはシンプルです。

> 「関数に渡すときは原則として `Readonly` で受け取り、破壊的変更(Mutation)を厳格に排除する。どうしても変更が必要なデータフローの場所だけ、意図的に型を切り替える」

—

4. 実戦で役立つベストプラクティス:Readonly配列とオブジェクト

オブジェクトだけでなく、配列(Array)も同様です。
「引数で受け取った配列を `push` や `sort` で破壊してほしくない!」というときは、`ReadonlyArray` や `readonly T[]` を使いましょう。

// ❌ 危険な関数シグネチャ
function processScores(scores: number[]) {
scores.push(100); // 呼び出し元の配列まで書き換わってしまう!
}

// ⭕️ 安全な関数シグネチャ
function processScoresSafely(scores: readonly number[]) {
// scores.push(100); // コンパイルエラー!破壊的メソッドは使えない

// 読み取りや非破壊的なメソッド(mapなど)は使える
return scores.map(s => s 2);
}

関数シグネチャを設計する際のチェックリストをまとめておきますね。

1. この関数はデータに変更を加えるか?

  • 加えない(純粋関数など)なら: 引数は必ず `Readonly` や `readonly T[]` にする。
  • 加えるなら: どのプロパティが変更されるのかが型レベルで明確になっているか確認する。

2. 呼び出し元への副作用はないか?

  • 参照渡しによる予期せぬバグを防ぐため、入り口(関数シグネチャ)の型を厳しく縛ることで、呼び出し元に対しても「ここは安全な関数だ」と保証する。

—

まとめ

いかがだったでしょうか?

  • `const` は変数の再代入を防ぐだけで、オブジェクトの中身の書き換えまでは防げない。
  • 関数シグネチャで `Readonly` や `readonly T[]` を活用することで、コンパイル時にデータの不変性を強制できる。
  • 「データはデフォルトでイミュータブル(変更不可)」という思想を型に落とし込むことで、バグの温床を根絶できる。

ここをクリアできれば、あなたの書くTypeScriptコードの信頼性は劇的に向上します。「あ、また型が守ってくれた!」という安心感を、ぜひ実際の開発現場でも味わってみてくださいね。

それでは、次回の記事もお楽しみに!バッチリマスターしていきましょう!

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