コードレビューの現場で:「なぜその引数は書き換え可能なのか?」
フロントエンドのコードベースが肥大化し、状態管理や非同期API連携のレイヤーが複雑化していくにつれ、最も厄介なバグの原因となるのは「意図しないオブジェクトのミューテーション(状態変更)」です。
例えば、UIコンポーネントに渡したPropsや、APIクライアントから返ってきたキャッシュオブジェクトが、子コンポーネントやユーティリティ関数の内部で知らぬ間に書き換えられていた――。Redux ToolkitやImmer全盛の時代であっても、プレーンなTypeScriptの関数境界において、不変性(Immutability)が型レベルで保証されていなければ、ランタイムの予期せぬバグに対する怯えながら開発を続けることになります。
TypeScriptの標準ライブラリには `Readonly
—
標準の `Readonly` が抱える構造的限界
まず、TypeScriptが標準で提供する `Readonly
type User = {
id: string;
profile: {
name: string;
settings: {
theme: ‘light’ | ‘dark’;
};
};
};
// 標準の Readonly を適用
const updateUserTheme = (user: Readonly
// コンパイルエラーになる(正しい)
// user.id = ‘999’;
// しかし、ネストしたプロパティは書き換え可能(型エラーにならない!)
user.profile.settings.theme = newTheme; // 😱 壊れてしまう
};
`Readonly
—
解決策:コンパイラを唸らせる `DeepReadonly` の実装
オブジェクトの階層構造を再帰的に巡回し、すべてのプロパティに `readonly` 修飾子を付与する条件付き型(Conditional Types)を構築します。
ここで重要になるのが、プリミティブ型、配列、関数、そしてプレーンオブジェクトを厳密に判別し、無限再帰や不要な型の破壊を防ぐアーキテクチャです。
/
- どんなに深くネストしたオブジェクトであっても、
- すべてのプロパティを再帰的に読み取り専用(Readonly)にする最高峰の型定義
/
export type DeepReadonly
// プリミティブ型(プリミティブのラッパー含む)や関数型の場合はそのまま返す
T extends Function | boolean | number | string | symbol | null | undefined
? T
// 配列の場合は、要素自体を DeepReadonly 化した ReadonlyArray に変換
: T extends Map
? ReadonlyMap
: T extends Set
? ReadonlySet
: T extends object
? { readonly [K in keyof T]: DeepReadonly
: T;
この型定義が優れている理由
1. 関数の保護: 関数型を `object` として誤判定してプロパティ走査してしまうのを防ぎ、呼び出しシグネチャを破壊しません。
2. コレクションの網羅: 配列だけでなく、`Map` や `Set` といった組み込みのコンテナ構造に対しても、それぞれ読み取り専用のバリアント(`ReadonlyMap`, `ReadonlySet`)を適用しています。
3. 分配条件型の回避: マップ型 `[K in keyof T]` を直接オブジェクトに対して適用することで、ユニオン型が渡された際意図しない挙動になるのを防ぎます。
—
プロダクションコードでの実践:堅牢なAPIデータハンドリング
実際のフロントエンド開発において、どのようにこの `DeepReadonly` を適用すべきか、APIレスポンスを処理するドメイン層のコードで見てみましょう。
// — ドメインモデルの定義 —
type OrderItem = {
productId: string;
quantity: number;
price: {
amount: number;
currency: ‘USD’ | ‘JPY’;
};
};
type Order = {
orderId: string;
items: OrderItem[];
shippingAddress: {
zipCode: string;
addressLine: string;
};
};
// — ビジネスロジック(純粋関数) —
/
- 注文データの合計金額を計算する関数
- 引数に DeepReadonly を強制することで、この関数の内部で
- 意図せずデータが書き換えられないことが「コンパイル時」に保証される。
/
export function calculateTotalAmount(order: DeepReadonly
// ❌ 以下のコードはすべてコンパイルエラーになる(バグの芽を完全排除)
// order.orderId = ‘hack-id’;
// order.items.push({ … });
// order.shippingAddress.zipCode = ‘000-0000’;
// order.items[0].price.amount = 0;
// 安全に読み取り専用としてアクセス・集計処理を行う
return order.items.reduce((total, item) => {
return total + item.quantity item.price.amount;
}, 0);
}
このアプローチを採用することで、コードレビュー時に「この関数は本当に副作用を持っていないか?」と疑心暗鬼になる必要がなくなります。TypeScriptのコンパイラが厳格なガードマンとして機能するためです。
—
パフォーマンスと型推論に関するアーキテクトからの注意点
ここで、高度なTypeScript使いだからこそ知っておくべき、コンパイルパフォーマンスと実務上のトレードオフについて言及しておきます。
1. 型評価のコスト(Instantiation Depth)
`DeepReadonly` は再帰的な型であるため、極端に深く複雑な型(数千行に及ぶスキーマや、循環参照を持つ型)に対して適用すると、TypeScriptのコンパイラ(tsc / TSServer)に多大な負荷をかけ、エディタの補完速度(IntelliSense)が著しく低下します。
- 対策: 巨大なサードパーティの型定義や、自動生成されたGraphQL / OpenAPIのスキーマ全体に一律で `DeepReadonly` を適用するのは避け、「ドメイン層の関数境界(ユースケースの入力値)」に絞って適用するのがベストプラクティスです。
2. アサーションやサードパーティライブラリとの統合
APIから返ってきた `unknown` なデータを `DeepReadonly
import { z } from ‘zod’;
// Zodのスキーマから型を推論し、DeepReadonlyで包む
const OrderSchema = z.object({
orderId: z.string(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number(),
price: z.object({
amount: z.number(),
currency: z.enum([‘USD’, ‘JPY’]),
}),
})),
shippingAddress: z.object({
zipCode: z.string(),
addressLine: z.string(),
}),
});
type ValidatedOrder = DeepReadonly
function handleApiResponse(rawJson: unknown) {
// ランタイムでのバリデーション通過後に、不変なドメインモデルとして扱う
const parsedData: ValidatedOrder = OrderSchema.parse(rawJson);
const total = calculateTotalAmount(parsedData); // 完璧に安全
return total;
}
—
まとめ:型は「ドキュメント」ではなく「契約」である
多くのジュニア〜ミドルクラスの開発者は、型定義を単なる「エディタの補完を効かせるための補足情報(ドキュメント)」と捉えがちです。しかし、チーフアーキテクトの視点において、型とは「システム全体で破ってはならない厳格な契約(Contract)」です。
`DeepReadonly` を適切に設計・導入することは、ランタイムのエラーを未然に防ぐだけでなく、チーム全体の開発メンタルモデルを「データをどう安全に保つか」という不安から解放し、「どうビジネスロジックを美しく組み立てるか」という本質的なクリエイティビティへとシフトさせます。
今日のコードレビューから、関数の引数にある `any` や生焼けのオブジェクトを見直してみませんか? 型の重みを知るエンジニアのコードは、常に美しく、そして絶対に壊れません。