「破壊的変更」を型で封じ込める:TypeScriptにおける不変性(Immutability)の強制術
コードレビューをしていて最も頭を抱える瞬間は、あるはずのない場所でデータが書き換わっているバグを見つけたときだ。
「なぜ、この関数を呼んだだけでプロパティが更新されているんだ?」
原因は明白だ。JavaScriptのオブジェクトはデフォルトで「参照渡し」であり、関数内で引数を不用意に操作すれば、呼び出し元の状態を汚染する。これを防ぐために「ドキュメントに注意書きを書く」のは古い。TypeScriptの型システムという強力な武器を使い、コンパイル時に破壊的変更を物理的に排除する設計こそが、現代のフロントエンド開発における正義だ。
本記事では、TypeScriptの型システムを駆使し、副作用のない関数を設計するための極限の知見を伝授する。
—
なぜ `Readonly` が必要なのか?
TypeScriptの型定義において、単に `object` や `{ id: number }` と書くだけでは、そのオブジェクトが「変更可能か否か」は表現できない。
// 悪い例:意図せず副作用を許容してしまう設計
function updateStatus(user: { status: string }) {
user.status = ‘active’; // 呼び出し元のオブジェクトも書き換わる
return user;
}
このコードの罪は、インターフェースが「読み取り専用なのか、書き込み可能なのか」という契約を隠蔽していることにある。これを防ぐには、TypeScriptの `Readonly
—
現場で使える「不変性強制」のベストプラクティス
1. インターフェース定義の段階で `readonly` を埋め込む
最も堅牢なのは、データ構造そのものに「書き換え不可」を明示することだ。
type User = {
readonly id: string;
readonly name: string;
readonly tags: ReadonlyArray
};
// これにより、コンパイラが破壊的変更を即座に検知する
function processUser(user: User) {
// user.name = “New Name”; // Error: Cannot assign to ‘name’ because it is a read-only property.
// user.tags.push(“admin”); // Error: Property ‘push’ does not exist on type ‘readonly string[]’.
return { …user, name: “New Name” }; // 変更が必要なら、スプレッド構文で新しいオブジェクトを生成する
}
2. 関数の引数に直接 `Readonly` を適用する
既存の外部ライブラリの型や、変更できない型定義を扱う場合は、関数定義側でガードをかける。
interface Configuration {
endpoint: string;
timeout: number;
}
// 引数にReadonlyを適用することで、関数内での破壊をコンパイルエラーにする
function initializeService(config: Readonly
// config.timeout = 5000; // Error!
console.log(`Connecting to ${config.endpoint}…`);
}
—
パフォーマンスと保守性のトレードオフを掌握する
「毎回新しいオブジェクトを生成(スプレッド構文)すると、メモリ効率が悪くなるのではないか?」という懸念を持つエンジニアもいるだろう。しかし、現代のJSエンジン(V8など)は非常に賢い。
1. 参照の共有: オブジェクトのコピーと言っても、ネストされていないプロパティは参照コピーされるため、コストは極めて低い。
2. バグのコスト vs メモリのコスト: 破壊的変更によって生じる「非決定的なバグ」を追跡する時間的コストは、メモリ消費の微増を遥かに上回る。
「不変性は、堅牢性のための必要経費」と割り切るのが、大規模開発の鉄則だ。
—
実践的テクニック:深い階層の不変性(Deep Readonly)
単純な `Readonly
// 再帰的にReadonlyを適用するユーティリティ型
type DeepReadonly
readonly [P in keyof T]: T[P] extends object ? DeepReadonly
};
interface AppState {
user: {
settings: { theme: ‘dark’ | ‘light’ };
};
}
function updateTheme(state: DeepReadonly
// state.user.settings.theme = ‘dark’; // Error! 深い階層まで保護される
}
—
結論:型を「制約」ではなく「設計」として使う
TypeScriptの型は、単なるドキュメントではない。コンパイラに対する「命令」だ。
- 引数には原則 `readonly` をつける。
- 変更が必要なら、常に新しいインスタンスを生成する。
- ネストされたデータには `DeepReadonly` でガードをかける。
この規律を守るだけで、あなたの書くコードの「予測可能性」は劇的に向上する。デバッグのためにコンソールを追いかける時間は減り、本質的なロジックの設計に集中できるはずだ。
コードは書く量よりも「破壊されない構造を作る」ことの方が価値がある。さあ、今すぐあなたのプロジェクトの型定義を見直し、`readonly` を追加することから始めてほしい。それが、プロのエンジニアの流儀だ。