【実務・中級編】Interfaceの「readonly」修飾子とImmutabilityの強制 – TypeScript コア・型システムの基礎解析バイブル

TypeScriptの「readonly」を極める:Immutable設計でバグを殲滅するアーキテクチャの極意

TypeScriptのコードレビューをしていると、`interface`のプロパティに漫然と`readonly`を付けたり、あるいは逆に「とりあえず`Readonly`で囲っておけば安全」という思考停止に陥っているコードをよく見かける。

だが、TypeScriptの型システムは単なる「お守り」ではない。コンパイル時の静的解析でランタイムのバグをいかに未然に防ぐか、そして型推論をどこまで最適化できるかという、設計思想そのものだ。

今回は、実務で遭遇する「不変性(Immutability)の罠」を解き明かし、堅牢なプロダクションコードを書くための指針を授ける。

—

1. Interfaceのreadonly修飾子:宣言による「契約」

まず、`interface`内での`readonly`は、そのオブジェクトを「外側から書き換え不可能にするための契約」だ。

interface UserProfile {
readonly id: string;
readonly createdAt: Date;
username: string; // これは変更可能
}

const user: UserProfile = { id: “123”, createdAt: new Date(), username: “dev_lead” };

// user.id = “456”; // Error: Cannot assign to ‘id’ because it is a read-only property.

この記述が強力なのは、「値の生成タイミングと、その後のライフサイクルで変更が生じないこと」を型レベルで担保できる点にある。特にAPIから受け取ったエンティティなど、変更されるべきではないドメインモデルには必須だ。

ここが落とし穴:readonlyは「深い(Deep)」ではない

多くの初心者が誤解しているのが、`readonly`は浅い(Shallow)という点だ。

interface Config {
readonly settings: {
theme: string;
};
}

const config: Config = { settings: { theme: “dark” } };
config.settings.theme = “light”; // !!エラーにならない!!

`settings`自体への再代入は防げるが、その配下のプロパティは書き換え可能だ。`readonly`を付与したからといって、オブジェクト全体が凍結(`Object.freeze`)されるわけではないことを心に刻んでほしい。

—

2. Readonly vs インラインreadonly:使い分けの黄金律

TypeScriptには組み込みのユーティリティ型`Readonly`がある。これとインラインの`readonly`をどう使い分けるべきか。

インライン `readonly` を使うべきケース

  • ドメインモデルの定義: `interface`自体にプロパティの不変性が組み込まれているべき場合。設計意図をコードの構造として明示できる。
  • パフォーマンス: 大規模な型定義において、Mapped Types(`Readonly`の正体)を多用すると、コンパイラの型推論負荷がわずかに上がる。複雑な型演算が絡むならインラインの方が速い。

`Readonly` を使うべきケース

  • 外部ライブラリの型: 自分で制御できないサードパーティ製のインターフェースを、一時的に不変として扱いたい場合。
  • 高階関数やユーティリティ: ジェネリクスを介して「どんな型でも不変にする」という抽象化を行いたい場合。

—

3. 実践:堅牢なコンポーネント設計のためのパターン

React等のフロントエンド開発において、PropsやStateを不変に保つことは、レンダリングの最適化(`React.memo`)にも直結する。実務で推奨される設計パターンを紹介しよう。

究極のDeepReadonly

前述の通り、標準の`Readonly`は浅い。実務では再帰的な`DeepReadonly`を定義しておくのが鉄則だ。

type DeepReadonly = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly : T[P];
};

interface AppState {
user: {
name: string;
preferences: { color: string };
};
}

const state: DeepReadonly = {
user: { name: “Alice”, preferences: { color: “blue” } }
};

// state.user.preferences.color = “red”; // コンパイルエラー!完璧な防御。

なぜこれが「保守性の高い美しいコード」なのか

1. 意図の明確化: このデータは「一度生成されたら絶対に変化しない」ことが、読み手(および未来の自分)に伝わる。
2. バグの削減: `useState`やReduxのReducer内で、誤ってオブジェクトのプロパティを直接変更するミューテーションを防げる。
3. 推論の安定: 型が静的であると確信できるため、エディタの補完が効きやすくなり、リファクタリング時の影響範囲特定が容易になる。

—

4. チーフアーキテクトからの忠告

最後に、現場で最も重要な心得を伝授する。

「不変性」を強制しすぎるな。

不変性は強力な武器だが、すべてを`readonly`にすると、複雑な更新処理(例えば、巨大なツリー構造の一部だけを更新する等)において、かえってコードが複雑化し、メモリを浪費する(オブジェクトのコピーを大量生成する)リスクがある。

  • ドメインモデル(Entity)は`readonly`で固める。
  • 一時的な演算用オブジェクト(VO/DTO)は柔軟性を持たせる。

この境界線をコードレビューで見極めることこそが、シニアエンジニアの仕事だ。ツールに踊らされるな。ツールを使って、コードという芸術を制御せよ。

次回のレビューでは、「なぜここに`readonly`がないのか?」と問いかける前に、「このデータはライフサイクルを通して変化する必要があるのか?」と自問自答することから始めてみてほしい。それが、TypeScriptを掌握する第一歩だ。

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