【実務・中級編】Interfaceのプロパティを「読み取り専用」にするべきか、Type Aliasで「不変性」を担保すべきか – TypeScript コア・型システムの基礎解析バイブル

【TypeScript設計論】`readonly` vs `Readonly`:型システムの深層から紐解く「真の不変性」の担保

コードレビューをしていて、以下のようなコードに出くくすことはないだろうか。

// よくある実装
interface User {
readonly id: string;
name: string;
}

type ReadonlyUser = Readonly;

「お、ちゃんと `readonly` を使ってイミュータブルにしようとしているな」とスルーしがちだが、ちょっと待ってほしい。その `readonly`、本当に意図した通りの不変性をコンパイル時に保証できているだろうか? そして、パフォーマンスやIDEの補完体験にどのような影響を与えているかを意識したことはあるだろうか。

TypeScriptの型システムは、単なる「型のラベル付け」ではない。コンパイラがコードの意図をどこまで静的に検証できるかという、設計の武器だ。

今回は、インターフェースの `readonly` 修飾子と、ユーティリティ型 `Readonly` の本質的な挙動の違いを暴き、大規模なフロントエンドの状態管理(Redux, Zustand, Reactのstateなど)において「バグの温床を完全に断つためのベストプラクティス」をテクニカルリードの視点から伝授しよう。

—

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` はどうだろうか? その実装を覗いたことはあるだろうか。TypeScriptの標準ライブラリ(`lib.es5.d.ts`)における `Readonly` の定義は極めてシンプルだ。

type Readonly = {
readonly [P in keyof T]: T[P];
};

これはMapped Types(写像型)のイディオムであり、指定された型 `T` のすべてのプロパティに `readonly` 修飾子を一括で付与しているに過ぎない。
つまり、`Readonly` もまた、本質的には 「浅い(Shallow)Readonly」 である。ネストされたオブジェクトの内部までは保護してくれない。

—

2. パフォーマンスとIDEの体験(DX)における違い

ここで、コンパイラとIDEの視点に立ってみよう。

1. 型チェックのオーバーヘッド:
Mapped Types(`Readonly`)を多用すると、TypeScriptの型推論エンジン(tsserver)は「型を動的に変形するコスト」を支払うことになる。巨大なスキーマに対して無闇に `Readonly` を挟み込むと、エディタの入力遅延(Typing lag)やビルド時間の肥大化を招く。
2. エラーメッセージの可読性:
明示的に `interface` の各プロパティに `readonly` を書いた場合、エラーメッセージにはそのプロパティ名が直接現れる。一方、複雑なユーティリティ型で包まれた型に対するエラーは、開発者に抽象的な型名(例: `Readonly`)を突きつけ、デバッグを困難にする。

実務の設計においては、「ベースの定義はインターフェースの `readonly` で硬く締め、関数の引数や返り値の境界領域でユーティリティ型を活用する」という住み分けが最も効率的だ。

—

3. 実践:完全な不変性(DeepReadonly)を担保する設計パターン

「ネストされた構造も含めて、一切の破壊的変更をコンパイル時に防ぎたい」――フロントエンドの状態管理ではこれが常態だ。
標準の `Readonly` では不十分であるため、プロダクションコードで即座に使える再帰的Readonly型(`DeepReadonly`)の決定版ティップスを共有しよう。

以下のコードを見てほしい。プリミティブ型、配列、オブジェクト、そして関数型までを正確に判別し、再帰的に `readonly` を伝播させる型定義だ。

/

  • 任意の型 T に対して、ネストされたプロパティや配列も含めて
  • すべてを完全な不変(Deep Readonly)にするユーティリティ型

/
export type DeepReadonly = T extends (infer R)[]
? DeepReadonlyArray
: T extends Function
? T
: T extends object
? DeepReadonlyObject
: T;

// 配列の再帰的イミュータビリティ
interface DeepReadonlyArray extends ReadonlyArray> {}

// オブジェクトの再帰的イミュータビリティ
type DeepReadonlyObject = {
readonly [K in keyof T]: DeepReadonly;
};

プロダクションコードでの活用例(Redux / Zustandライクな状態管理)

この `DeepReadonly` を、実際のフロントエンドの状態管理レイヤーに適用してみよう。APIから受け取った複雑なドメインモデルを安全にストアに保持し、コンポーネント側での「うっかりミューテーション」を完全にコンパイルエラーで弾く設計だ。

// — 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` や組み込みの `ReadonlyArray` を活用する。
3. グローバルステート・複雑なツリー:
ReduxのStateやZustandのStore、または複雑なJSONスキーマを扱う場合は、迷わず今回紹介した `DeepReadonly` を採用し、アプリ全体に「真の不変性」を強制する。

型はドキュメントであり、同時にランタイムの安全性を守る防壁だ。「動けばいいや」という妥協を捨て、TypeScriptの型システムを極限までハックすることで、大規模開発であってもスケールする堅牢なフロントエンドアーキテクチャを築き上げてほしい。

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