TypeScriptの型システムで「副作用」を封印する:Readonly強制の流儀
コードレビューをしていて、最も頭を抱える瞬間の一つが「関数が渡された引数をこっそり書き換えている」のを発見した時だ。
「この関数、純粋な値変換だと思ったのに、実は参照元のオブジェクトまで汚染していたのか」
このバグは往々にして、コンポーネントの再レンダリングを阻害し、非同期処理の競合を引き起こし、デバッグの難易度を地獄へと変える。JavaScriptのオブジェクトはデフォルトで「ミュータブル(可変)」だ。この言語仕様の穴を埋めるのは、我々TypeScript使いの責務である。
今日は、型システムを駆使して「副作用をコンパイル時に物理的に抹消する」ための、実務的な設計パターンを伝授する。
—
1. なぜ `Readonly` を強制するのか
単に「引数を変えないでね」と口頭で約束しても、大規模開発では確実に破られる。重要なのは、「プログラミング言語の型チェッカーが、書き込みを物理的に拒絶する状態」を作ることだ。
まず、最も基本的なアプローチを見てみよう。
type User = {
id: string;
settings: { theme: ‘light’ | ‘dark’ };
};
// ❌ 悪い例:引数がミュータブルなまま
function updateTheme(user: User): void {
// コンパイラはこれを許可してしまう(副作用の温床)
user.settings.theme = ‘dark’;
}
このコードの何が問題か。`user` オブジェクトがどこから渡されてきたか追跡できない大規模なフロントエンドにおいて、この書き換えは破壊的な影響を及ぼす。これを防ぐための「防御的プログラミング」がこちらだ。
—
2. 実践:Readonlyによる「副作用の封印」パターン
TypeScriptには `Readonly
// ユーティリティ型:再帰的に全てのプロパティを読み取り専用に
type DeepReadonly
readonly [P in keyof T]: T[P] extends object ? DeepReadonly
};
interface State {
config: { endpoint: string };
items: string[];
}
// ⭕️ 良い例:Readonlyを強制する設計
function processState(state: DeepReadonly
// state.config.endpoint = ‘…’ // ❌ コンパイルエラー!
// state.items.push(‘new’) // ❌ コンパイルエラー!
console.log(`Processing: ${state.config.endpoint}`);
}
なぜこれが「美しい」のか
1. 認知負荷の低下: この関数を見るだけで、開発者は「ああ、これは値を読み取るだけで、元の状態を壊さないんだな」と瞬時に理解できる。
2. 不変性の保証: `Array.prototype.push` やプロパティの代入が型レベルでブロックされるため、意図しない副作用が入り込む余地がない。
—
3. パフォーマンスと型評価のコスト
「何でもかんでも `DeepReadonly` にすればいいのか?」という問いに対しては、NOと答える。
TypeScriptの型評価は、複雑になればなるほどコンパイラの計算リソース(メモリとCPU)を消費する。非常に巨大な型に対して再帰的な変換をかけると、エディタのレスポンスが鈍くなることがある。
- 実務上の最適解: APIレスポンスやグローバルな状態管理のトップレベルにのみ `DeepReadonly` を適用し、関数シグネチャには `Readonly
` を使う。あるいは、必要なプロパティだけを絞り込んだインターフェースを定義する。
—
4. プロダクションコードでの応用:非同期API連携
Reactのコンポーネントや、Next.jsのAPIルートなどでよくある「データ加工」の現場で最も効果を発揮するパターンを紹介する。
interface ApiResponse {
data: { list: number[] };
}
// レスポンスを受け取り、安全に加工して返す(元データは保護)
function calculateTotal(response: Readonly
// 破壊的メソッド(sortなど)はコンパイルエラーになるため、
// 必ずスプレッド演算子やtoSorted()などの非破壊メソッドを使うようになる
return […response.data.list].reduce((acc, cur) => acc + cur, 0);
}
この「元のオブジェクトをいじれない」という制約が、逆に「コピーを作って加工する」という関数型プログラミングのベストプラクティスを強制することになる。これが堅牢なコードへの近道だ。
—
結論:型は「ドキュメント」ではなく「ガードレール」
TypeScriptにおいて、型定義は単なる注釈ではない。それはコードの挙動を制限し、チームメイトの「うっかりミス」を未然に防ぐガードレールだ。
- 引数には可能な限り `Readonly` をつけること。
- 副作用が必要なら、明示的に新しいオブジェクトを生成して返すこと。
この二つを徹底するだけで、あなたの書くコードの「バグ発生率」は劇的に下がる。コンパイラを信頼し、コンパイラに厳しい制約を課すこと。それこそが、伝説的なコードを書くための唯一の道だ。
さあ、今すぐプロジェクトの型定義を見直し、`readonly` の海でコードを浄化してくれ。健闘を祈る。