【実務・中級編】関数の引数に「Readonly」を強制し、副作用のない関数を設計する – TypeScript コア・型システムの基礎解析バイブル

「破壊的変更」を型で封じ込める:TypeScriptにおける不変性(Immutability)の強制術

コードレビューをしていて最も頭を抱える瞬間は、あるはずのない場所でデータが書き換わっているバグを見つけたときだ。

「なぜ、この関数を呼んだだけでプロパティが更新されているんだ?」

原因は明白だ。JavaScriptのオブジェクトはデフォルトで「参照渡し」であり、関数内で引数を不用意に操作すれば、呼び出し元の状態を汚染する。これを防ぐために「ドキュメントに注意書きを書く」のは古い。TypeScriptの型システムという強力な武器を使い、コンパイル時に破壊的変更を物理的に排除する設計こそが、現代のフロントエンド開発における正義だ。

本記事では、TypeScriptの型システムを駆使し、副作用のない関数を設計するための極限の知見を伝授する。

—

なぜ `Readonly` が必要なのか?

TypeScriptの型定義において、単に `object` や `{ id: number }` と書くだけでは、そのオブジェクトが「変更可能か否か」は表現できない。

// 悪い例:意図せず副作用を許容してしまう設計
function updateStatus(user: { status: string }) {
user.status = ‘active’; // 呼び出し元のオブジェクトも書き換わる
return user;
}

このコードの罪は、インターフェースが「読み取り専用なのか、書き込み可能なのか」という契約を隠蔽していることにある。これを防ぐには、TypeScriptの `Readonly` ユーティリティ型、あるいは `readonly` 修飾子を強制すべきだ。

—

現場で使える「不変性強制」のベストプラクティス

1. インターフェース定義の段階で `readonly` を埋め込む

最も堅牢なのは、データ構造そのものに「書き換え不可」を明示することだ。

type User = {
readonly id: string;
readonly name: string;
readonly tags: ReadonlyArray; // 配列も再帰的にreadonlyにする
};

// これにより、コンパイラが破壊的変更を即座に検知する
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` は、ネストされたオブジェクトの階層までは保護できない。プロフェッショナルな設計を目指すなら、再帰的な `DeepReadonly` 型を定義しておくべきだ。

// 再帰的にReadonlyを適用するユーティリティ型
type DeepReadonly = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly : T[P];
};

interface AppState {
user: {
settings: { theme: ‘dark’ | ‘light’ };
};
}

function updateTheme(state: DeepReadonly) {
// state.user.settings.theme = ‘dark’; // Error! 深い階層まで保護される
}

—

結論:型を「制約」ではなく「設計」として使う

TypeScriptの型は、単なるドキュメントではない。コンパイラに対する「命令」だ。

  • 引数には原則 `readonly` をつける。
  • 変更が必要なら、常に新しいインスタンスを生成する。
  • ネストされたデータには `DeepReadonly` でガードをかける。

この規律を守るだけで、あなたの書くコードの「予測可能性」は劇的に向上する。デバッグのためにコンソールを追いかける時間は減り、本質的なロジックの設計に集中できるはずだ。

コードは書く量よりも「破壊されない構造を作る」ことの方が価値がある。さあ、今すぐあなたのプロジェクトの型定義を見直し、`readonly` を追加することから始めてほしい。それが、プロのエンジニアの流儀だ。

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