【TypeScript設計論】`readonly` vs `Readonly
コードレビューをしていて、以下のようなコードに出くくすことはないだろうか。
// よくある実装
interface User {
readonly id: string;
name: string;
}
type ReadonlyUser = Readonly
「お、ちゃんと `readonly` を使ってイミュータブルにしようとしているな」とスルーしがちだが、ちょっと待ってほしい。その `readonly`、本当に意図した通りの不変性をコンパイル時に保証できているだろうか? そして、パフォーマンスやIDEの補完体験にどのような影響を与えているかを意識したことはあるだろうか。
TypeScriptの型システムは、単なる「型のラベル付け」ではない。コンパイラがコードの意図をどこまで静的に検証できるかという、設計の武器だ。
今回は、インターフェースの `readonly` 修飾子と、ユーティリティ型 `Readonly
—
1. 挙動の根本的違い:表面の `readonly` と深層のイミュータビリティ
まず、TypeScriptコンパイラがこれらをどう評価しているのか、そのメカニズムに踏り込む。
インターフェースの `readonly` 修飾子
インターフェースや型の各プロパティに付与する `readonly` は、「そのプロパティへの直接の再代入」をコンパイル時に禁止する。
interface Point {
readonly x: number;
readonly y: number;
}
const p: Point = { x: 10, y: 20 };
// p.x = 30; // ❌ Error: Cannot assign to ‘x’ because it is a read-only property.
しかし、これには有名な落とし穴がある。ネストされたオブジェクトや配列に対しては、外側の `readonly` は波及しない(浅い不変性 / Shallow Immutability)。
interface Team {
readonly name: string;
readonly members: string[]; // 配列自体はreadonlyだが…
}
const team: Team = { name: ‘Alpha’, members: [‘Alice’, ‘Bob’] };
// team.members = [‘Charlie’]; // ❌ 再代入はエラーになる
team.members.push(‘Charlie’); // ⭕️ 通ってしまう!配列の中身は変更可能
ユーティリティ型 `Readonly` の正体
では、`Readonly
type Readonly
readonly [P in keyof T]: T[P];
};
これはMapped Types(写像型)のイディオムであり、指定された型 `T` のすべてのプロパティに `readonly` 修飾子を一括で付与しているに過ぎない。
つまり、`Readonly
—
2. パフォーマンスとIDEの体験(DX)における違い
ここで、コンパイラとIDEの視点に立ってみよう。
1. 型チェックのオーバーヘッド:
Mapped Types(`Readonly
2. エラーメッセージの可読性:
明示的に `interface` の各プロパティに `readonly` を書いた場合、エラーメッセージにはそのプロパティ名が直接現れる。一方、複雑なユーティリティ型で包まれた型に対するエラーは、開発者に抽象的な型名(例: `Readonly
実務の設計においては、「ベースの定義はインターフェースの `readonly` で硬く締め、関数の引数や返り値の境界領域でユーティリティ型を活用する」という住み分けが最も効率的だ。
—
3. 実践:完全な不変性(DeepReadonly)を担保する設計パターン
「ネストされた構造も含めて、一切の破壊的変更をコンパイル時に防ぎたい」――フロントエンドの状態管理ではこれが常態だ。
標準の `Readonly
以下のコードを見てほしい。プリミティブ型、配列、オブジェクト、そして関数型までを正確に判別し、再帰的に `readonly` を伝播させる型定義だ。
/
- 任意の型 T に対して、ネストされたプロパティや配列も含めて
- すべてを完全な不変(Deep Readonly)にするユーティリティ型
/
export type DeepReadonly
? DeepReadonlyArray
: T extends Function
? T
: T extends object
? DeepReadonlyObject
: T;
// 配列の再帰的イミュータビリティ
interface DeepReadonlyArray
// オブジェクトの再帰的イミュータビリティ
type DeepReadonlyObject
readonly [K in keyof T]: DeepReadonly
};
プロダクションコードでの活用例(Redux / Zustandライクな状態管理)
この `DeepReadonly
// — 1. ドメインモデルの定義 —
interface UserProfile {
readonly id: string;
name: string; // ストア内ではここも不変にしたい
settings: {
theme: ‘dark’ | ‘light’;
notifications: {
email: boolean;
push: boolean;
};
};
tags: string[];
}
// — 2. ステートの型定義に DeepReadonly を適用 —
type RootState = DeepReadonly<{
currentUser: UserProfile | null;
isLoading: boolean;
}>;
// — 3. モックステートの初期化 —
const initialState: RootState = {
currentUser: {
id: ‘usr_001’,
name: ‘Architect’,
settings: {
theme: ‘dark’,
notifications: {
email: true,
push: false,
},
},
tags: [‘admin’, ‘core-team’],
},
isLoading: false,
};
// — 4. イミュータブルな状態更新関数の実装例 —
function updateTheme(state: RootState, newTheme: ‘dark’ | ‘light’): RootState {
if (!state.currentUser) return state;
// —————————————————-
// 以下のような破壊的変更は、すべてコンパイルエラーになる!
// —————————————————-
// state.currentUser.name = ‘Hacker’;
// ❌ Error: Cannot assign to ‘name’ because it is a read-only property.
// state.currentUser.settings.theme = newTheme;
// ❌ Error: Cannot assign to ‘theme’ because it is a read-only property.
// state.currentUser.tags.push(‘guest’);
// ❌ Error: Property ‘push’ does not exist on type ‘DeepReadonlyArray
// —————————————————-
// したがって、安全にスプレッド構文等で新しいオブジェクトを生成するアプローチへ強制誘導される
return {
…state,
currentUser: {
…state.currentUser,
settings: {
…state.currentUser.settings,
theme: newTheme,
},
},
};
}
このコードの美しさは、「不安全なコードを書く自由をコンパイラが奪ってくれる」点にある。開発者がレビューで「ここミュータブルになってますよ」と指摘するコストがゼロになり、TypeScriptの型チェッカーがその役割を完全に肩代わりしてくれるのだ。
—
4. チーフアーキテクトからの提言:どう使い分けるべきか
最後に、現場のコードベースにこの設計を持ち込む際の方針をまとめる。
1. 基本のドメインエンティティ:
インターフェースを定義する際は、主要な識別子や変更されては困るプロパティに明示的に `readonly` を付与する。これにより、IDEのインテリセンス(補完)でドキュメント的に「何がイミュータブルであるべきか」が即座に伝わる。
2. 関数・コンポーネントの境界(Props / 引数):
外部から受け取るデータや、変更を加えたくない設定オブジェクトの受け渡しには `Readonly
3. グローバルステート・複雑なツリー:
ReduxのStateやZustandのStore、または複雑なJSONスキーマを扱う場合は、迷わず今回紹介した `DeepReadonly
型はドキュメントであり、同時にランタイムの安全性を守る防壁だ。「動けばいいや」という妥協を捨て、TypeScriptの型システムを極限までハックすることで、大規模開発であってもスケールする堅牢なフロントエンドアーキテクチャを築き上げてほしい。