TypeScript Interfaceの `readonly` と真のImmutability:ランタイムを侵食しない型安全の極意
コードレビューをしていて、次のようなコードに遭遇したことはないだろうか。
interface User {
readonly id: string;
readonly name: string;
}
const user: User = { id: “1”, name: “Taro” };
user.name = “Jiro”; // ❌ コンパイルエラー!素晴らしい!
「よし、これで不変性(Immutability)は担保された」――そう思った瞬間から、あなたの設計のほころびが始まる。TypeScriptの `readonly` は、ランタイムのJavaScriptにおいて何のエンドフォース(強制力)も持たない「コンパイル時のみの幻影」に過ぎない。
今回は、フロントエンドの状態管理、コンポーネント設計、そして外部API連携の現場において、`readonly` をどう手なずけ、真の堅牢なImmutabilityを構築するのか。テクニカルリードの視点から、その極限の知見を授けよう。
—
1. `readonly` の正体:コンパイル時の型安全性とランタイムの現実
まず、TypeScriptのコンパイラが `readonly` をどう扱っているかを知る必要がある。
TypeScriptは、構造的型付け(Structural Subtyping)を採用している。Interfaceのプロパティに付与された `readonly` は、「この変数・プロパティを経由した書き込み操作を型チェッカーが静的に弾く」ためのものであり、出力されるJavaScriptコードには一切影響を与えない。
// 上記のTypeScriptがトランスパイルされた結果(ES2022以降でも)
const user = { id: “1”, name: “Taro” };
user.name = “Jiro”; // ⚠️ ランタイムでは普通に書き換わる!
さらに、次のような「構造の互換性」の罠にハマるエンジニアが後を絶たない。
interface WritableUser {
id: string;
name: string;
}
const mutableUser: WritableUser = { id: “1”, name: “Taro” };
const readonlyUser: User = mutableUser; // ✅ 代入可能(共変性により、mutableはreadonlyに代入できる)
// 逆は?
// const backToMutable: WritableUser = readonlyUser;
// ❌ コンパイルエラー:readonlyプロパティを持つ型は、mutableな型には代入できない
TypeScriptの型システムにおいて、`readonly` は「書き込み権限の剥奪」を意味する。したがって、書き込み権限を持つオブジェクトを、読み取り専用として扱う分には安全(共変)だが、その逆は許されない。この特性を理解していれば、関数引数の設計方針が自ずと見えてくるはずだ。
—
2. 実務の現場における設計パターン:ディープな不変性の担保
単一のInterfaceに `readonly` をつけるだけでは、ネストされたオブジェクトの前では無力である。
interface Address {
city: string;
zipCode: string;
}
interface UserProfile {
readonly id: string;
readonly address: Address; // ⚠️ ここに注目!Address自体はreadonlyではない
}
const profile: UserProfile = {
id: “100”,
address: { city: “Tokyo”, zipCode: “100-0001” }
};
// コンパイルは通ってしまう!
profile.address.city = “Osaka”;
実務のAPIレスポンスやコンポーネントのPropsにおいて、浅い(Shallow)`readonly` はバグの温床となる。これを解決するのが、TypeScriptのユーティリティ型、あるいはカスタムのディープ・イミュータブル型だ。
プロダクションコードで使うべき `DeepReadonly` パターン
以下のコードは、あらゆる階層のプロパティを再帰的に `readonly` 化する、実務で即座に使える堅牢なパターンである。
/
- 再帰的にすべてのプロパティをreadonlyにする実用的なユーティリティ型
/
export type DeepReadonly
? T
: T extends Map
? ReadonlyMap
: T extends Set
? ReadonlySet
: T extends object
? { readonly [K in keyof T]: DeepReadonly
: T;
// — 使用例 —
interface ApiPayload {
user: {
id: string;
permissions: string[];
};
}
const payload: DeepReadonly
user: {
id: “uuid-999”,
permissions: [“read”, “write”]
}
};
// ❌ すべてコンパイルエラーで弾かれる
// payload.user.id = “hacked”;
// payload.user.permissions.push(“admin”);
—
3. パフォーマンスと開発者体験(DX)のトレードオフ
「じゃあ、すべてのInterfaceの全プロパティとネスト構造に `readonly` と `DeepReadonly` を適用すれば最強だな!」と思ったなら、少し待ってほしい。型システムにもコストがある。
1. 型チェックのコンパイル負荷
複雑な再帰的Mapped Types(`DeepReadonly`など)を巨大なスキーマ(例えば、OpenAPIから自動生成された数万行の型定義など)に適用すると、TypeScriptの型チェッカー(TSServer)のメモリ消費量が増大し、エディタのインテリセンスが重くなる現象(いわゆる Type instantiation is excessively deep and possibly infinite のリスク)を引き起こす。
【プラクティス】
- 境界領域(APIクライアントのレスポンスや、Redux/Zustandなどのグローバルステートのルート)でのみ `DeepReadonly` を使い、局所的なコンポーネントのPropsでは浅い `readonly` または組込の `Readonly
` で留める。
2. イミュータブル更新のボイラープレート
`readonly` を厳格に適用すると、オブジェクトの更新時にスプレッド構文 (`…`) の嵐になる。
// readonlyな状態を安全に更新するパターン
const nextState = {
…currentState,
user: {
…currentState.user,
permissions: […currentState.user.permissions, “execute”]
}
};
もしこれが深すぎるネストであれば、コードの可読性が著しく低下する。その場合は、 Immer などの構造的共有(Structural Sharing)を行うライブラリを導入し、ランタイムの安全性を担保しつつ、型としても親和性の高い設計に落とし込むのがモダンWeb開発の定石である。
—
4. コンポーネント設計・非同期API連携における実践知
最後に、実際のフロントエンド開発(React等)を想定した、保守性の高いプロダクションコードの全体像を示す。
import React from ‘react’;
// ==========================================
// 1. ドメインモデルの定義(境界でのDeepReadonly)
// ==========================================
interface Task {
readonly id: string;
readonly title: string;
readonly metadata: {
readonly priority: ‘LOW’ | ‘MID’ | ‘HIGH’;
readonly tags: readonly string[]; // 配列自体も読み取り専用
};
}
type DomainState = DeepReadonly<{ tasks: Task[]; isLoading: boolean; }>;
// ==========================================
// 2. コンポーネントのProps設計
// ReactのPropsには最初から Readonly
// ==========================================
interface TaskItemProps {
readonly task: Task;
readonly onToggle: (id: string) => void; // 状態変更はイベントを発火させる(Duxパターン)
}
export const TaskItem: React.FC
return (
{/
task.metadata.tags.push(‘urgent’);
↑ コンパイルエラー:ReadonlyArrayにはpushが存在しないため、バグを未然に防げる
/}
);
};
// ==========================================
// 3. 非同期APIレイヤーでの振る舞い
// ==========================================
async function fetchTaskDetails(taskId: string): Promise
const response = await fetch(`/api/tasks/${taskId}`);
const data: Task = await response.json();
// 外部からの入力を安全にイミュータブルとしてアプリ内に取り込む
return data as DeepReadonly
}
—
チーフアーキテクトからの提言
TypeScriptの `readonly` は、単なる「お行儀の良い書き方を強制する機能」ではない。それは、「アプリケーション全体でデータの流れを単方向(Unidirectional)にし、意図しない変異(Mutation)によるバグをコンパイル段階で根絶するための強力な契約(Contract)」である。
コードレビューで `readonly` のないオブジェクトの再代入や破壊的メソッド(`push`, `splice` 等)を見かけたら、こう問いかけてほしい。
> 「そのオブジェクト、本当に途中で姿形を変える必要ありますか?」
型システムを味方につけた者だけが、変化の激しいフロントエンド開発において、真の「保守性の高い美しいコードベース」を手に入れることができる。今日から君のInterfaceにも、迷わず `readonly` を刻み込んでくれ。