TypeScriptで「不変(Immutable)」を極める:`readonly`の深淵と再帰的解決
こんにちは。TypeScriptのコードベースを日々見ていると、時折「なぜかデータが勝手に書き換わっている」というバグに遭遇することがあります。大規模なアプリケーションになるほど、データがどこで変化したのかを追跡するのは至難の業です。
そこで重要になるのが「不変性(Immutability)」という概念です。今回は、TypeScriptにおける `readonly` 修飾子の基礎から、ネストされたオブジェクトまでを完全に掌握する「再帰的イミュータビリティ」の設計までを、深く掘り下げて解説します。
—
1. `readonly` 修飾子の基礎:コンパイラの監視の目
まずは基本です。TypeScriptにおいて `readonly` は、コンパイル時に「このプロパティを書き換えてはいけない」という制約をかける強力なツールです。
interface User {
readonly id: number;
name: string;
}
const user: User = { id: 1, name: “Alice” };
// 読み取りはOK
console.log(user.id);
// エラー: Cannot assign to ‘id’ because it is a read-only property.
user.id = 2;
ここまでは簡単ですよね。しかし、ここで一つ重要な注意点があります。「`readonly` は浅い(Shallow)制約である」ということです。
—
2. 潜む罠:`readonly` は「中身」まで守ってくれない
多くの初学者が陥る罠がこれです。`readonly` をつけても、オブジェクトの参照先にあるプロパティまで守られるわけではありません。
interface Settings {
readonly theme: {
color: string;
};
}
const settings: Settings = {
theme: { color: “dark” }
};
// これはエラーになりません!
settings.theme.color = “light”;
なぜエラーにならないのでしょうか? `settings.theme` 自体は `readonly` ですが、「`theme` が指し示しているオブジェクトの中身」は `readonly` ではないからです。コンパイラは「`theme` の参照先を変えることは許さないが、その先にあるプロパティの変更までは感知しない」という挙動をとります。
これが、実務で意図しないバグを生む最大の原因です。
—
3. 再帰的イミュータビリティ:`DeepReadonly` の実装
では、この問題をどう解決するか。TypeScriptの強力な型システム、Mapped TypesとRecursive Types(再帰型)を駆使して、すべての階層を強制的に `readonly` にする型を作りましょう。
// 再帰的にreadonlyを適用する型
type DeepReadonly
readonly [P in keyof T]: T[P] extends object ? DeepReadonly
};
interface Config {
user: {
name: string;
preferences: {
lang: string;
};
};
}
// すべての階層が読み取り専用になります
const config: DeepReadonly
user: {
name: “Bob”,
preferences: { lang: “ja” }
}
};
// これら全てがコンパイルエラーになります!
// config.user.name = “Charlie”;
// config.user.preferences.lang = “en”;
このコードの仕組みを紐解く
1. `[P in keyof T]`: オブジェクトの全てのキーをループします。
2. `readonly`: 各プロパティに `readonly` を付与します。
3. `T[P] extends object ? DeepReadonly
これで、どんなに深くネストされた構造であっても、コンパイラが全ての変更を許さなくなります。
—
4. 現場で知っておくべき「型アサーション」との付き合い方
`readonly` を使っていると、時には「どうしても一時的に値を書き換えたい」という状況が発生することもあります。その際、安易に `any` にキャストするのではなく、型システムの意図を汲むことが大切です。
// 非推奨:型安全性を破壊する
(user as any).id = 2;
// 推奨:どうしても必要な場合は、新しいオブジェクトを作成する(イミュータブルな更新)
const updatedUser = { …user, id: 2 };
不変データ構造の真髄は、「書き換える」のではなく「新しい状態を作成する」ことにあります。この考え方を身につけると、Reactなどの状態管理や、関数型プログラミング的なアプローチが格段にスムーズになります。
—
最後に:TypeScriptはあなたの「相棒」です
`readonly` や再帰型を導入するのは、最初は少し面倒に感じるかもしれません。しかし、これらは「未来の自分」や「チームメンバー」がバグに苦しまないための、最も効率的な防壁です。
コンパイラに「ここから先は触らせない」と指示を出すことは、コードの信頼性を担保する究極の規律です。ぜひ、今日からあなたのコードに `DeepReadonly` を取り入れて、堅牢なアプリケーション設計を楽しんでくださいね。
ここをクリアしたあなたは、もうTypeScriptの型の本質を掴んでいます。自信を持って、次のステップへ進んでいきましょう!