【実務・中級編】Interfaceの「readonly」修飾子とImmutabilityの強制:不変データ構造の設計 – TypeScript コア・型システムの基礎解析バイブル

TypeScriptで「不変性」を強制する:`readonly`のその先へ

現場のコードレビューでよく見かける光景がある。「とりあえず `interface` で型定義を作りました」というプルリクエストだ。しかし、そのオブジェクトが外部から意図せず書き換えられ、副作用の温床となっていることに気づいていない。

JavaScriptのオブジェクトはデフォルトで「可変(Mutable)」だ。これは言語設計上の性質だが、大規模なフロントエンドアプリケーションにおいて、この柔軟性は時に「悪」となる。特にReduxのような状態管理や、複雑なコンポーネントのPropsにおいて、不変性(Immutability)の担保はバグを未然に防ぐための最強の防御壁だ。

今回は、単なる `readonly` 修飾子を超え、TypeScriptの型システムを駆使して「再帰的な不変性」を強制する設計論を叩き込む。

—

1. なぜ `readonly` 修飾子だけでは不十分なのか

まず、基本を再確認しよう。`interface` で `readonly` を付与するのは第一歩に過ぎない。

interface User {
readonly id: number;
readonly profile: {
name: string;
};
}

const user: User = { id: 1, profile: { name: “Alice” } };

// コンパイルエラー: Cannot assign to ‘id’ because it is a read-only property.
// user.id = 2;

// しかし、ネストされたオブジェクトは書き換わってしまう!
user.profile.name = “Bob”; // これが通ってしまうのが問題だ

TypeScriptの `readonly` は「浅い(Shallow)不変性」しか提供しない。プロパティ自体がオブジェクトである場合、その中身の書き換えまでは防げないのだ。これが大規模開発における予期せぬ状態変化の元凶となる。

—

2. `DeepReadonly`:再帰的イミュータビリティの強制

この問題を解決するには、Mapped Typesを使って再帰的に型を変換する必要がある。私がアーキテクトとしてプロジェクトに必ず導入するユーティリティ型がこれだ。

/

  • Tがオブジェクトなら、すべてのプロパティを再帰的にreadonlyにする

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

この型を適用すると、コンパイラはネストの深さに関係なく、すべての階層に対して「書き換え禁止」を突きつける。

実践:プロダクションコードでの活用例

APIから取得したレスポンスを扱うレイヤーで、この型をキャストして利用する。これにより、UIコンポーネントに渡すデータが「読み取り専用」であることを型レベルで保証できる。

interface ApiResponse {
data: {
user: { name: string; roles: string[] };
meta: { version: number };
};
}

// APIレスポンスをDeepReadonlyで包む
const response = (await fetchApi()) as DeepReadonly;

// 以下はすべてコンパイルエラーになる
// response.data.user.name = “Hack”;
// response.data.user.roles.push(“admin”);

—

3. パフォーマンスとコンパイラの重み

「すべての型を再帰的に変換したら、コンパイル時間が長くなるのでは?」という懸念を持つエンジニアは鋭い。実際、複雑な再帰型はTypeScriptの型推論エンジン(`tsserver`)に負荷をかける。

  • 解決策: むやみにすべてのインターフェースを `DeepReadonly` にするのは避けること。
  • 運用指針: 「状態管理の境界(State Store)」や「API通信のDTO(Data Transfer Object)」など、アプリケーションの境界線(Boundary)で一度だけ型変換を行うのがベストプラクティスだ。

内部で頻繁に計算や変更を行うワークスペースでは、あえて `Mutable` な型を使い、出力時にのみ `as const` や `DeepReadonly` を適用して「不変な状態」として公開する。この「書き込みと読み取りの分離」こそが、堅牢な設計の極意である。

—

4. 現場で使える「不変データ構造」の極意

最後に、実務ですぐに使える設計パターンを提示する。

アプローチA:`as const` によるリテラル型の固定

動的な生成物ではなく、設定値や定数に対しては、型定義を書くよりも `as const` を使え。

const CONFIG = {
apiEndpoint: “https://api.example.com”,
retries: 3,
} as const;

// CONFIG.retries = 4; // Error!

アプローチB:TypeScript 3.4+ の `Readonly` との併用

`interface` を定義する際、最初から `readonly` を付ける癖をつけろ。後から「ここは書き換える可能性があるか?」と考えるのではなく、「デフォルトで不変、必要なら `Mutable` 型を自作して外す」というスタンスをとる方が、圧倒的にバグが少ない。

type Mutable = {
-readonly [P in keyof T]: T[P];
};

—

結論:型は「ドキュメント」ではなく「制約」である

TypeScriptの型システムは、IDEの補完を出すための親切なガイドではない。バグを論理的に不可能にするための制約(Constraint)だ。

`readonly` を正しく使いこなし、再帰的な不変性を担保することで、あなたの書くコードは「動く」だけでなく「壊れにくい」ものへと進化する。次のプルリクエストでは、インターフェースのすべてのキーに `readonly` が付いているか、一度立ち止まって確認してほしい。

それが、プロのエンジニアが持つべき「言語への敬意」であり、プロダクトを守るための技術的規律だ。

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