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` が付いているか、一度立ち止まって確認してほしい。
それが、プロのエンジニアが持つべき「言語への敬意」であり、プロダクトを守るための技術的規律だ。