テックリードの私だ。コードレビューで「とりあえず `any` を貼る」「動くからよしとする」というプルリクエストを見かけるたびに、私の胃酸は限界を迎える。
今日のテーマは「関数シグネチャにおける `Readonly` プロパティの伝播と注意点」だ。
フロントエンドの状態管理、コンポーネントのProps設計、そして非同期API層。あらゆる場所で「イミュータビリティ(不変性)」の担保は現代Web開発の生命線となっている。しかし、TypeScriptの `Readonly
「引数に `Readonly
残念ながら、その認識のままでは複雑なドメインモデルを扱う大規模アプリケーションで必ず痛い目を見る。今日は、型システムがコンパイル時にどう評価し、実行時にどんな罠が潜んでいるのかを、プロダクションコードレベルの知見とともに叩き込む。
—
1. なぜ `Readonly` の伝播で躓くのか?(深層の型評価)
まず大前提として、TypeScriptの `Readonly
以下のコードを見てほしい。よくある典型的なアンチパターンだ。
type User = {
id: string;
profile: {
name: string;
age: number;
};
};
// 浅いReadonlyの適用
function updateUserName(user: Readonly
// コンパイルエラー: 根のプロパティは保護されている
// user.id = “2”;
// しかし、ネストしたオブジェクトは書き換え可能!
user.profile.name = newName; // ⚠️ コンパイルエラーにならない!
}
なぜ `user.profile.name` の書き換えが通ってしまうのか?
`Readonly
これが「プロパティの伝播が途切れる瞬間」だ。
—
2. 堅牢な設計:再帰的(Deep)イミュータビリティの強制
実務の現場でAPIレスポンスやRedux/Zustandのストア、複雑なフォーム状態を扱う場合、浅い `Readonly` では無力だ。関数シグネチャの段階で、ネストの深さに関わらず完全にイミュータブルであることをコンパイラに保証させなければならない。
そこで、真に再帰的な `DeepReadonly` 型を定義し、それを関数シグネチャに適用する設計パターンを見ていこう。
プロダクションコード例:型安全なデータパイプライン
以下のコードは、ネストしたデータ構造を受け取り、一切の副作用(ミューテーション)をコンパイルレベルで排除したデータ処理パイプラインの設計例だ。
/
- 任意の深さを持つオブジェクトを完全に読み取り専用にするユーティリティ型
/
type DeepReadonly
? DeepReadonlyArray
: T extends Function
? T
: T extends object
? DeepReadonlyObject
: T;
// 配列用ヘルパー
interface DeepReadonlyArray
// オブジェクト用ヘルパー
type DeepReadonlyObject
readonly [K in keyof T]: DeepReadonly
};
// — ドメインモデルの定義 —
type OrderItem = {
productId: string;
quantity: number;
metadata: {
warehouseId: string;
tags: string[];
};
};
type Order = {
orderId: string;
items: OrderItem[];
shippingAddress: {
zipCode: string;
street: string;
};
};
// — 関数シグネチャでの活用 —
/
- 注文データを検証し、計算結果を返す純粋関数(Pure Function)
- 引数に DeepReadonly を指定することで、関数内での偶発的なミューテーションを完全に封じ込める
/
function calculateOrderTotal(order: DeepReadonly
// 以下の操作はすべてコンパイルエラーになる。
// order.orderId = “123”; // Error: 読み取り専用
// order.items[0].quantity = 10; // Error: 配列の要素も読み取り専用
// order.items[0].metadata.tags.push(“urgent”); // Error: 配列メソッドのミューテーションもブロック
// 安全にイミュータブルな計算を行える
return order.items.reduce((total, item) => {
return total + item.quantity 100; // 単価100円と仮定
}, 0);
}
// — 使用例 —
const rawOrder: Order = {
orderId: “ORD-001”,
items: [
{
productId: “PROD-A”,
quantity: 2,
metadata: { warehouseId: “WH-1”, tags: [“fragile”] },
},
],
shippingAddress: {
zipCode: “100-0001”,
street: “Chiyoda-ku Tokyo”,
},
};
// 通常のオブジェクトを渡しても、TypeScriptの共変性により
// DeepReadonly
const total = calculateOrderTotal(rawOrder);
console.log(`Total: ${total}`); // Total: 200
この設計の美しい点は、「呼び出し元(Caller)に余計な変更を強いない」ことだ。TypeScriptの型システムにおいて、ミュータブルな型はイミュータブルな型へ代入可能(共変性)であるため、開発者は通常のオブジェクトをそのまま関数に渡せる。しかし、関数の中に入った瞬間からコンパイラは厳格なガードマンとなり、一切の書き換えを阻止する。
—
3. パフォーマンス上の注意点:型推論とコンパイル速度のトレードオフ
さて、テクニカルリードとしてパフォーマンスの話も避けて通れない。
「じゃあ、すべてのオブジェクト型に `DeepReadonly` を貼れば最強だな!」と思ったそこのあなた、少し待ってほしい。
複雑な再帰型(Recursive Types)を多用すると、TypeScriptのコンパイラ(TSServer)の型チェックのパフォーマンスが著しく低下する。
1. 型の評価コスト
`DeepReadonly` はオブジェクトのすべてのプロパティのキーを再帰的に走査(Mapped Types)する。巨大なスキーマ(例えば、OpenAPIから生成された数千行におよぶAPI型定義など)に対して無差別に `DeepReadonly` を適用すると、IDEの補完(IntelliSense)が重くなり、CIでのビルド時間が確実に肥大化する。
【対策】
- アプリケーションの境界(APIレスポンスの受け口や、グローバルステートのルートなど)でのみ `DeepReadonly` を適用し、細かなコンポーネントのPropsでは浅い `Readonly
` や `readonly` 修飾子で済ませる。 - パフォーマンスクリティカルな型定義では、TypeScript 4.5以降で導入された `Awaited` や条件エンティティの評価コストを意識し、必要十分な深さに留める。
2. 実行時のオーバーヘッドはない(が、イミュータビリティの維持コストに注意)
TypeScriptの `Readonly` はコンパイル時のみの概念であり、JavaScriptのランタイムには一切コードが出力されない(`Object.freeze()` のような実行時イミューテーション防止は行われない)。
もし実行時にも完全な不変性を担保したい場合は、別途 `Object.freeze()` や、Immer等のライブラリを組み合わせる必要がある。しかし、フロントエンドのレンダリングパフォーマンスを考慮する場合、過剰な `Object.freeze` はV8エンジンの最適化(隠しクラスの変更など)を阻害することがあるため、ドメインの重要度に応じて使い分けること。
—
4. まとめ:プロフェッショナルな型設計へ向けて
関数シグネチャにおける `Readonly` の扱いは、単なる「型エラーを防ぐための呪文」ではない。「この関数は入力されたデータを決して汚さない」という、他の開発者(そして未来の自分自身)への最強のドキュメントであり、契約(Contract)なのだ。
今日からあなたのコードレビューの基準を一段引き上げよう。
関数がオブジェクトを受け取るシグネチャを見かけたら自問してほしい。
- 「この引数は、本当にシャローなReadonlyで足りているか?」
- 「ネストしたプロパティが書き換えられてバグを生むリスクはないか?」
この意識を持てるかどうかが、保守性と拡張性に優れたプロダクトメンテナーへの分かれ道だ。
さて、次のプルリクエストのレビューに戻るとしよう。君たちの健闘を祈る。