【実務・中級編】Interfaceのreadonly修飾子とImmutabilityの保証 – TypeScript コア・型システムの基礎解析バイブル

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 extends Function
? T
: T extends Map
? ReadonlyMap, DeepReadonly>
: 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 = ({ task, onToggle }) => {
return (

{task.title}
{/
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` を刻み込んでくれ。

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