【実務・中級編】関数型における「DeepReadonly」を引数に適用し不変性を保証する設計 – TypeScript コア・型システムの基礎解析バイブル

伝播するイミュータビリティ:DeepReadonlyがつむぐ、型安全なフロントエンドアーキテクチャ

コードレビューをしていて、次のようなコードに遭遇したことはないだろうか。

interface UserState {
profile: {
name: string;
settings: {
theme: ‘light’ | ‘dark’;
};
};
}

function updateTheme(state: UserState, newTheme: ‘light’ | ‘dark’) {
// うっかりネストされたプロパティを直接書き換えてしまっている
state.profile.settings.theme = newTheme;
}

TypeScriptの標準ライブラリが提供する `Readonly` は非常に便利だが、その効力はルートレベルのプロパティにしか及ばない。浅い(Shallow)イミュータビリティなのだ。オブジェクトの階層が深くなればなるほど、この「型による防壁」は容易に突破され、意図しないミューテーション(状態変異)がバグの温床となる。

特にReactのState管理、ReduxのReducer、あるいは複雑なドメインモデルを扱うフロントエンド開発において、ランタイムの予期せぬオブジェクト書き換えは、UIの再描画漏れや追跡不可能なバグを引き起こす。

今回は、TypeScriptの型システムを極限まで駆動させ、ネストされたすべての階層を再帰的に凍結する『DeepReadonly』の設計と、それを実務のコードベースに定着させるための実践知を伝授する。

—

1. DeepReadonlyのコア実装:型パズルを超えた「コンパイル時防壁」

まずは、妥協のない `DeepReadonly` の実装を見てほしい。ネット上に転がっている単純な再帰型では、配列やプリミティブ型、そして関数型に直面したときに破綻する。型チェッカーの挙動を完全にコントロールしたプロダクション品質のコードがこれだ。

/

  • プリミティブ型や関数型、特殊なオブジェクトを安全に判定しつつ、
  • すべてのプロパティを再帰的に readonly 化する型定義

/
export type DeepReadonly = T extends (infer U)[]
? ReadonlyArray>
: T extends Map
? ReadonlyMap, DeepReadonly>
: T extends Set
? ReadonlySet>
: T extends Function
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly }
: T;

この実装が「本物」である理由

1. 配列の完全なイミュータビリティ:
`T extends (infer U)[]` により、配列そのものだけでなく、配列の要素(element)に至るまで `ReadonlyArray` として再帰的に凍結する。`push` や `splice` といった破壊的メソッドをコンパイルエラーとして弾く。
2. コレクション(Map / Set)の考慮:
モダンなフロントエンドでは `Map` や `Set` を状態管理に使うケースも多い。これらも `ReadonlyMap` / `ReadonlySet` に変換し、キーや値の意図せぬ書き換えを防ぐ。
3. 関数のスルー:
`T extends Function`。関数はオブジェクトの一種として判定されやすいため、誤ってシグネチャが破壊されるのを防ぐためにそのままスルーさせる。

—

2. 実践:APIレスポンスからUIコンポーネントまでを貫く設計

では、この `DeepReadonly` を実際のフロントエンド開発でどう適用すべきか。
「APIから受け取った生データ(DTO)」を、「UI層で安全に扱うための読み取り専用モデル」へと昇華させるアーキテクチャを見てみよう。

// — 1. ドメインモデル / DTOの定義 —
interface ApiResponse {
id: string;
metadata: {
createdAt: string;
tags: string[];
};
config: Map;
}

// — 2. 関数引数への DeepReadonly の適用 —
/

  • UIコンポーネントやプレゼンター層で、データの安全性を保証する関数

/
function renderDashboard(data: DeepReadonly) {
// コンパイルエラー: 読み取り専用のため代入できない
// data.metadata.createdAt = ‘2025-01-01’;

// コンパイルエラー: 読み取り専用配列のため push できない
// data.metadata.tags.push(‘new-tag’);

console.log(`Rendering Dashboard for: ${data.id}`);
console.log(`Latest tag: ${data.metadata.tags[0]}`);
}

// — 3. 実行時のモックデータ —
const rawApiResponse: ApiResponse = {
id: ‘usr_999’,
metadata: {
createdAt: ‘2023-10-01’,
tags: [‘admin’, ‘active’],
},
config: new Map([[‘featureFlag’, true]]),
};

// 型アサーション、あるいはAPIクライアントの戻り値として DeepReadonly を強制
renderDashboard(rawApiResponse as DeepReadonly);

この設計の強みは、「関数に渡された瞬間に、開発者が誤ってオブジェクトを汚染するリスクが型レベルで消滅する」という点にある。レビュー時に「ここ、ミューテーション起きてない?」と疑う必要すらなくなる。型がそれを証明しているからだ。

—

3. パフォーマンスとコンパイル負荷の罠:チーフアーキテクトからの警告

ここで、コンパイラAPIやTypeScriptの内部挙動に関わる重要な注意点を共有しておこう。

再帰型(Conditional Typesを用いた再帰)は、TypeScriptコンパイラ(TSServer)にとって重い処理である。巨大なスキーマや、循環参照を含む複雑なオブジェクトに対して `DeepReadonly` を乱用すると、IDEの補完速度(インテリセンス)が著しく低下し、最悪の場合はコンパイルがタイムアウトする。

対策:型評価のキャッシュと境界線の設定

1. DTOの境界(Boundary)でのみ適用する
すべての内部変数やヘルパー関数の引数に `DeepReadonly` をつける必要はない。

  • 「外部から境界を越えて入ってきたデータ(APIレスポンス、LocalStorageからの復元値など)」
  • 「グローバルな状態管理(Store)のルート型」

これらシステムのエントリポイントとなる境界でのみ `DeepReadonly` を適用し、内部の小さなユーティリティ関数では通常の型や `Readonly` で十分なケースを見極めること。

2. `any` や `unknown` の混入を防ぐ
`DeepReadonly` は `any` をスルーしてしまうか、予期せぬ挙動を引き起こす。厳格な型(`strict: true`)の環境下で運用することが大前提となる。

—

4. まとめ:型は「ドキュメント」ではなく「契約」である

多くのジュニア〜ミドルクラスの開発者は、型を「エディタの補完を効かせるための補足情報」や「静的なドキュメント」程度にとどめがちだ。

しかし、シニア・アーキテクチャの視点において、型とは「実行不可能なバグをコンパイル時に駆逐するための厳格な契約(Contract)」である。

今回紹介した `DeepReadonly` をチームの共通基盤に組み込むことで、チームメンバーのスキルセットに依存せず、常に「不変性が担保された堅牢なデータフロー」を維持できるようになる。

次のコードレビューで、ネストされたオブジェクトの書き換えバグを見かけたら、ぜひこの `DeepReadonly` を提示し、チームのコードベースを一段上のレイヤーへと引き上げてほしい。

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