【入門編】関数シグネチャにおける「Readonly」プロパティの伝播と注意点 – TypeScript コア・型システムの基礎解析バイブル

こんにちは!TypeScriptの世界へようこそ。
フロントエンドからバックエンドまで、型安全で頑健なアプリケーションを構築していく上で、関数とデータの関係性を見つめ直すことは避けて通れない重要なステップです。

他の言語(JavaやC#など)からやってきた開発者の方によくあるのが、「関数にオブジェクトを渡したら、知らないうちに中身が書き換えられてバグった(副作用の発生)」という悩みです。TypeScriptには、これをコンパイル時(コードを書いている瞬間)にピシャリと防いでくれる、心強い味方がいます。それが `Readonly` ユーティリティ型です。

ここをクリアすれば、あなたの書くコードの安全性と美しさは一段と跳ね上がりますよ。一緒に本質をマスターしていきましょう!

—

1. なぜ「書き換え不可(イミュータブル)」が関数にとって重要なのか?

まずは、私たちが普段やりがちな「危ういコード」のイメージから見てみましょう。
JavaScriptやTypeScriptでは、オブジェクトは「参照(メモリ上の住所)」で渡されます。そのため、関数にオブジェクトを渡すと、その関数は元のオブジェクトの「実家」に入り込んで、家具の配置を勝手に変えてしまうようなことができてしまいます。

// ユーザー情報の型
type User = {
name: string;
age: number;
};

// データを処理するつもりの関数
function updateAge(user: User) {
user.age += 1; // 恐ろしいことに、渡された元のオブジェクトを直接書き換えている!
return user;
}

const originalUser = { name: ‘Alice’, age: 20 };
const updatedUser = updateAge(originalUser);

console.log(originalUser.age); // アッ! 21 になってる!
// originalUser 自身が変わってしまうことを「副作用 (Side Effect)」と呼びます。

「関数に渡しただけなのに、元のデータまで書き換わっちゃった……」というのは、大規模開発になればなるほど、バグの温床になります。
これを防ぐために登場するのが `Readonly` です。

—

2. `Readonly` の基本的な使い方とコードの意味

`Readonly` は、TypeScriptがあらかじめ用意してくれている「組み込みの魔法の型(ユーティリティ型)」の一つです。
指定した型 `T` のすべてのプロパティに、自動的に `readonly` 修飾子を貼付けて、「後から書き換えられないよ」というシグネチャ(契約)に変換してくれます。

百聞は一見にしかず、実際のコードを見てみましょう。

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

// 引数の型を Readonly にする!
function printUserInfo(user: Readonly) {
console.log(`名前: ${user.name}, 年齢: ${user.age}`);

// 【コンパイルエラー!】
// 読み取り専用なので、値の再代入は許されません。
// user.age = 30;
// > エラー: Cannot assign to ‘age’ because it is a read-only property.
}

const myUser: User = { name: ‘Bob’, age: 25 };
printUserInfo(myUser);

このように、関数の引数の型を `Readonly` と定義してあげるだけで、「この関数の中でこのオブジェクトのプロパティを書き換えようとすると、TypeScriptが赤く波線を引いて怒ってくれる」ようになります。

実行時ではなく、コードを書いているエディタ上でバグを未然に防げる。これがTypeScriptの真骨頂ですね。

—

3. ここに注意!「Readonly」の伝播(プロパティの深さ)に関する罠

さて、ここからが少し踏み込んだ「プロレベル」の知見です。他の言語から来た方が一番ハマりやすいポイントがここにあります。

実は、標準の `Readonly` は、「浅い(シャロー)Readonly」です。つまり、オブジェクトの「一番表面のプロパティ」しか読み取り専用にしてくれません。

オブジェクトの中に、さらに別のオブジェクト(ネストした構造)がある場合、どうなるでしょうか?

type Address = {
city: string;
zipCode: string;
};

type Member = {
name: string;
address: Address; // 内部に別のオブジェクトがある!
};

function processMember(member: Readonly) {
// 1. 表面のプロパティは当然ガードされる
// member.name = ‘Charlie’; // ❌ エラーになる!

// 2. 内部のオブジェクトのプロパティはどうなる…?
member.address.city = ‘Tokyo’; // ⭕️ なんと、エラーにならずに通ってしまう!
}

なぜ `member.address.city` の変更が通ってしまうのでしょうか?
それは、`Readonly` が適用したのは `Member` 型の表面(`name` と `address` という箱)だけであり、`address` の中身(`Address` 型のプロパティ)までは再帰的に `readonly` 化してくれていないからです。

これが、関数シグネチャにおける「Readonlyの伝播の限界」です。

深くまで完全にイミュータブルにするには?(DeepReadonly)

もし、ネストしたオブジェクトも含めて完全に変更不可にしたい場合は、自分で「深くまで読み取り専用にする型(DeepReadonly)」を定義する必要があります。
少し高度ですが、チーフアーキテクトからのスペシャルギフトとしてコードをシェアしておきますね。

// 再帰的にすべてのプロパティを readonly にするカスタム型
type DeepReadonly = T extends Function
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly }
: T;

type DeepMember = DeepReadonly;

function processSafeMember(member: DeepMember) {
// member.address.city = ‘Tokyo’;
// > ❌ 今度はここもしっかりコンパイルエラーで守られます!
}

このように、TypeScriptの型システムは、再帰(Recursion)を使ってオブジェクトの構造の奥深くまで型を浸透させることができます。

—

4. まとめ:関数シグネチャで安全な設計を手に入れよう

今回は、関数における `Readonly` プロパティの伝播と、その注意点について解説しました。

  • `Readonly` を使う理由: 関数内での意図しないデータの書き換え(副作用)をコンパイル時に防ぐため。
  • 注意点: 標準の `Readonly` は「浅い(表面のみ)」ため、ネストしたオブジェクトの変更までは防げない。
  • 発展: 完全にデータを守りたい場合は、再帰的な型(`DeepReadonly`)の活用を検討する。

「関数には読み取り専用のデータだけを渡す」というルールを型で強制できるようになると、コードの見通しが劇的に良くなり、バグに怯える夜から解放されます。

ここをクリアできれば、あなたのTypeScriptの基礎力はもうバッチリマスターできていますよ!自信を持って次のステップへ進んでくださいね。それでは、また次回の記事でお会いしましょう!

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