開発の現場でコードレビューをしていると、いまだに次のようなコードに遭遇することがある。
interface User {
id: string;
name: string;
permissions: string[];
}
function updateUserProfile(user: User, newPermissions: string[]): User {
// うっかり破壊的変更を行ってしまう悪夢の始まり
user.permissions.push(…newPermissions);
return user;
}
フロントエンドの状態管理(ReduxやZustand、ReactのStateなど)や、堅牢なAPIクライアント層において、「意図しないミューテーション(破壊的変更)」はバグの温床となる。TypeScriptを使っているにもかかわらず、関数の引数レベルで「読み取り専用(Readonly)」と「書き換え可能(Mutable)」の境界が曖昧になっていることが、この問題の本質だ。
今回は、関数シグネチャにおける `Readonly` と `Mutable` の混在を完全にコンパイル時で防ぎ、実行時の安全性をゼロコストで担保する型設計の極意を伝授しよう。
—
なぜ `Readonly` だけでは実務で不十分なのか?
TypeScriptの組み込み型である `Readonly
なぜなら、`Readonly
type ShallowReadonlyUser = Readonly
const user: ShallowReadonlyUser = {
id: “1”,
name: “Taro”,
permissions: [“read”]
};
// コンパイルエラーになる(正しい)
// user.name = “Jiro”;
// しかし、ネストされた配列のミューテーションはすり抜ける!(危険)
user.permissions.push(“write”); // ⚠️ コンパイルエラーにならない!
この「型の穴」を放置したまま「関数内ではイミュータブルに扱う」というドキュメント上の規約に頼るのは、エンジニアリングの敗北だ。コンパイラに語らせるべきである。
—
解決策:深層不変性(Deep Immutability)を強制する型設計
真に堅牢な関数シグネチャを作るためには、再帰的にすべてのプロパティとコレクションを凍結するカスタムユーティリティ型 `DeepReadonly
以下のプロダクションコードを見てほしい。これが、モダンなTypeScriptにおけるフロントエンド・バックエンド共通のベストプラクティスだ。
/
- プリミティブ型、関数、またはオブジェクト・配列を再帰的に完全な読み取り専用にする
/
export type DeepReadonly
? ReadonlyArray
: T extends Function
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly
: T;
/
- 破壊的変更(Mutable)を明示的に許可すべきデータ構造であることを示すマーカー型
/
export type Mutable
-readonly [K in keyof T]: T[K] extends object ? Mutable
};
この型設計の優れている点
1. 配列の保護: `T extends (infer R)[]` により、通常の配列だけでなくタプルや配列自体のメソッド(`push`, `pop`, `splice` 等)も型レベルで一切使用できなくする(`ReadonlyArray` に変換)。
2. 関数の保持: 関数型(`Function`)は不変性の対象外として正しくスルーする。
3. 明示的な脱出ハッチ (`Mutable
—
実践:コンポーネント・非同期API層でのプロダクションコード例
では、この `DeepReadonly` を実際のフロントエンド開発(APIレスポンスのハンドリングと状態更新)にどう適用するか。実用的なコードを示す。
// — Domain Models —
interface Task {
readonly id: string;
readonly title: string;
readonly tags: string[];
}
interface Project {
readonly id: string;
readonly name: string;
readonly tasks: Task[];
}
// — API Client / Service Layer —
/
- APIから取得したデータは、アプリケーション層に渡す前に DeepReadonly でラップ、
あるいは最初から型定義を厳格にしておく。
/
async function fetchProjectDetail(projectId: string): Promise
const response = await fetch(`/api/projects/${projectId}`);
const data: Project = await response.json();
// 型アサーションではなく、コンパイル時保証としてDeepReadonlyを適用
return data as DeepReadonly
}
// — State Management / Business Logic Layer —
/
- ❌ 悪い例: 引数の型が曖昧で、内部で破壊的変更が起きてしまう可能性がある
/
// function badUpdateTaskTitle(project: Project, taskId: string, newTitle: string): Project { … }
/
- ✅ 良い例: DeepReadonlyにより、関数内での偶発的なミューテーションを完全にコンパイルエラーにする
/
function updateTaskTitleInProject(
project: DeepReadonly
taskId: string,
newTitle: string
): DeepReadonly
// 構造的共有(Structural Sharing)を用いたイミュータブルな更新
return {
…project,
tasks: project.tasks.map((task) => {
if (task.id !== taskId) return task;
// task.title = newTitle;
// ↑もしここでうっかり書き換えようとすると、
// TS2540: Cannot assign to ‘title’ because it is a read-only property. と即座に怒られる。
return {
…task,
title: newTitle,
};
}),
};
}
// — 使用例 —
async function handleUserAction(projectId: string) {
// 1. 取得したデータは不変として扱われる
const project = await fetchProjectDetail(projectId);
// project.name = “New Name”; // ❌ コンパイルエラー!
// 2. 更新関数に渡す。戻り値も確実に DeepReadonly な新しいオブジェクトが返る
const updatedProject = updateTaskTitleInProject(project, “task-1”, “Refactor TypeScript types”);
console.log(updatedProject);
}
—
パフォーマンス上の注意点とアーキテクチャの知見
「すべての型を `DeepReadonly` にしたら、コンパイル速度が落ちるのではないか?」という懸念を持つ優秀なエンジニアもいるだろう。
結論から言うと、極端に深くネストされたオブジェクト(深さ50階層以上など)の再帰的型評価は、TypeScriptの型推論エンジン(tsserver)に数ミリ秒の負荷をかける。しかし、一般的なWebアプリケーションのドメインモデル(深さ3〜5階層程度)であれば、パフォーマンスへの影響は完全に無視できるレベルだ。むしろ、実行時における不意のバグ調査や、不必要な再レンダリング(Reactのメモ化効率化)のメリットのほうが圧倒的に大きい。
テクニカルリードからの提言
1. 境界(Boundary)で型を厳格にする: 外部API(JSON)やレガシーコードとの境界を跨ぐ瞬間だけ `as unknown as DeepReadonly
2. 「Readonlyの混在」をコードレビューのフックにする: 関数シグネチャに `Readonly` がついている引数と、ついていない引数が混ざっている場合、その関数が「副作用を持っているか(あるいは不必要に元データを触ろうとしているか)」のシグナルになる。コードレビューでは、ここを厳しくチェックしてほしい。
TypeScriptの型システムは、単なる補完ツールではない。「バグが入り込む余地のあるコードの書き方を、コンパイルエラーによって物理的に禁止する」ための最強の防壁である。
今日の設計から `DeepReadonly` を導入し、チーム全体のコードベースの品位を一段上のステージへと引き上げよう。