【入門編】関数型における「Readonly」と「DeepReadonly」の引数への適用と不変性の保証 – TypeScript コア・型システムの基礎解析バイブル

こんにちは!TypeScriptの型システムの世界へようこそ。
フルスタックエンジニアの先輩として、今日から君をワンランク上のTypeScript使いへと導いていきますね。

ここをクリアすれば、TypeScriptの基本はバッチリマスターできますよ!

さて、今回私たちが向き合うのは「関数における不変性(イミュータビリティ)の保証」です。
実務でコードを書いていると、「この関数に渡した設定オブジェクトやデータ構造を、関数内の処理でうっかり書き換えて(ミューテーション)しまって、思わぬバグを生んだ……」なんて経験、ありませんか?

TypeScriptの標準機能である `Readonly` と、それをさらに進化させた `DeepReadonly` を使いこなせば、そんなヒューマンエラーをコンパイル時に100%防ぐことができるんです。

さあ、型システムの奥深い扉を開けてみましょう!

—

1. なぜ「書き換え禁止(Readonly)」が必要なのか?

まずは、JavaScript/TypeScriptが持つ「参照渡し」の性質からおさらいしましょう。
オブジェクトや配列を関数に渡すとき、私たちは実体(メモリ上のアドレス)ではなく「参照」を渡しています。

そのため、悪気なく関数内でこんなことをしてしまうと……

type User = {
name: string;
settings: {
theme: string;
};
};

function updateTheme(user: User) {
// うっかり元のオブジェクトを書き換えてしまった!
user.settings.theme = “dark”;
}

このコード、動いてはしまいますが、呼び出し元のデータまでこっそり書き換えてしまう(サイドエフェクトの発生)ため、大規模なアプリケーションではバグの温床になります。

ここでTypeScriptの登場です。「この関数に渡すデータは、絶対に書き換えさせない!」と型でガチガチに固めてしまいましょう。

—

2. 標準の `Readonly` の限界を知る

TypeScriptには、標準でオブジェクトの直下のプロパティを読み取り専用にする `Readonly` という便利なユーティリティ型が用意されています。

まずはこれを使ってみましょう。

type User = {
name: string;
settings: {
theme: string;
};
};

// 標準の Readonly を適用
function printUser(user: Readonly) {
// これはOK(読み取り)
console.log(user.name);

// 【コンパイルエラー!】直下のプロパティは書き換えられない
// user.name = “Bob”;

// あれっ……? これはエラーにならない!?
user.settings.theme = “dark”;
}

おや? `user.settings.theme = “dark”` がエラーになりませんね。
なぜでしょうか?

実は、標準の `Readonly` は「浅い(Shallow)コピー」ならぬ「浅い読み取り専用化」しかしてくれないのです。`user` という箱のフタは閉めましたが、その中にある `settings` という「入れ子(ネスト)の箱」のフタは開いたままになっている、というイメージですね。

実務で扱うデータは、APIレスポンスをはじめとして深くネストしていることがほとんどです。この「浅さ」の壁を突破するのが、今回本気でマスターする `DeepReadonly` です。

—

3. 降臨:すべてを凍結する `DeepReadonly`

ネストしたオブジェクトの隅々まで、すべてのプロパティに `readonly` の呪文をかけ続けるにはどうすればよいでしょうか?
ここで、TypeScriptの「条件付き型(Conditional Types)」と「再帰(Recursion)」という強力なコンパイラ機能を使います。

まずは、その魔法のコードを見てください。

// 再帰的にすべてのプロパティを Readonly にする型定義
type DeepReadonly =
T extends Function ? T : // 関数型はそのまま通す
T extends object ? {
readonly [K in keyof T]: DeepReadonly; // プロパティを再帰的に凍結
} :
T; // プリミティブ型(string, number等)はそのまま返す

このコードの仕組み(脳内トレース)

1. `T extends object`: 渡された型がオブジェクト(配列やオブジェクト)かどうかを判定します。
2. Mapped Types (`{ [K in keyof T]: … }`): オブジェクトのすべてのキー `K` を取り出し、それぞれのプロパティに `readonly` 修飾子を付与します。
3. 再帰呼び出し (`DeepReadonly`): ここが核心です!プロパティの値 `T[K]` に対して再度 `DeepReadonly` を適用します。これにより、何階層下にネストしたオブジェクトや配列であろうとも、すべてくまなく凍結されます。

—

4. 実戦投入:`DeepReadonly` で安全な関数を書く

先ほどの `DeepReadonly` を使って、先ほどの `printUser` 関数を完璧に守ってみましょう。

// (先ほど定義した DeepReadonly がここにあるとします)

type User = {
name: string;
settings: {
theme: string;
notifications: {
email: boolean;
};
};
};

function processUserSecurely(user: DeepReadonly) {
// 1階層目はもちろん読み取り専用
// user.name = “Alice”; // ❌ コンパイルエラー!

// 2階層目のネストも……
// user.settings.theme = “light”; // ❌ コンパイルエラー!

// 3階層目の奥深くにあるプロパティも……!
// user.settings.notifications.email = false; // ❌ コンパイルエラー!

console.log(`Processing user: ${user.name}`);
}

const myUser: User = {
name: “Ken”,
settings: {
theme: “dark”,
notifications: { email: true }
}
};

// 安全にデータを渡せる
processUserSecurely(myUser);

どうですか? これで、関数内部でうっかりデータを破壊してしまうバグは、実行する前にTypeScriptのコンパイラが完全にシャットアウトしてくれます。

—

まとめ:型で「意図」をコードに刻もう

今回は、関数型における `Readonly` と、それを極限まで深掘りした `DeepReadonly` について解説しました。

  • 標準の `Readonly` は直下のプロパティしか守れない(浅い)。
  • 自作の `DeepReadonly` は、条件付き型と再帰を使ってネストしたオブジェクトの隅々までイミュータビリティを保証できる(深い)。

TypeScriptの型システムは、単なる「エラーチェックツール」ではありません。「この関数には、このデータ構造を絶対に改変させない」という開発者の強い意志(デザイン・インテント)をコードに刻み込むための最強の武器です。

ここをクリアした君なら、もう複雑なAPIデータ構造を扱うことも怖くないはず。
明日からの開発で、ぜひこの `DeepReadonly` を取り入れて、堅牢なコードベースを作ってみてくださいね!

それでは、次のステップでお会いしましょう。ハッピー・コーディング!

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