【実務・中級編】 Readonlyによる読み取り専用化 – TypeScript実践ガイド

やあ、調子はどうだい?
最近、コードレビューをしていて「あ、ここ、うっかり書き換えられちゃってるよ……!」ってバグを踏むことが多くて頭を悩ませてないかい?特にReduxのステートや、APIから取ってきた初期設定オブジェクトなんかね。

フロントエンドの規模が大きくなればなるほど、「どこで誰がこのデータを書き換えたんだ!?」っていう不毛な犯人探しに時間を溶かすことになる。この地獄から抜け出すための特効薬が、今回解説する `Readonly` だ。

今回は、中級からもう一段階上の「型を制するシニア」へステップアップするための、実戦的な `Readonly` の話をしよう。教科書的な使い方だけじゃなく、ランタイムの動きや、実務でやりがちな「浅い罠」についても包み隠さず話していくから、最後までコーヒーでも飲みながら読んでくれよな。

—

1. `Readonly` とは何か?(基本のおさらい)

TypeScriptの組み込み型(Utility Types)の一つである `Readonly` は、文字通り、ジェネリック型 `T` が持つすべてのプロパティに `readonly` 修飾子を付与し、イミュータブル(読み取り専用)にする魔法の型だ。

まずは、百聞は一見に如かず。コードを見てみよう。

// ユーザー情報を表す型
interface User {
id: number;
name: string;
}

// 通常のオブジェクト
const mutableUser: User = {
id: 1,
name: “Taro”,
};

// 後から書き換えが可能(お馴染みの状態)
mutableUser.name = “Jiro”; // OK!

// Readonly でラップしたオブジェクト
const readonlyUser: Readonly = {
id: 1,
name: “Taro”,
};

// ここでコンパイルエラーが発生する!
// ❌ Cannot assign to ‘name’ because it is a read-only property.
readonlyUser.name = “Jiro”;

めちゃくちゃシンプルだろ?
「これの何が凄いの? 最初から `interface` のプロパティに `readonly` つければいいじゃん」って思ったそこの君、鋭いね。

でも、考えてみてほしい。他人が作ったライブラリの型や、APIのレスポンス型、あるいは「ある文脈では書き換え可能にしたいけど、別の関数には絶対に読み取り専用として渡したい」というケースにおいて、元の型を汚さずにその場でサクッとイミュータブルを強制できるのが `Readonly` の真骨頂なんだ。

—

2. ブラウザの裏側で何が起きているのか?(TypeScriptの幻想とJSの現実)

ここでシニアとして君に絶対に知っておいてほしい「残酷な真実」がある。

TypeScriptの `Readonly` は、あくまで「コンパイル時(開発時)の型チェック」のためのものだ。ブラウザが実行するJavaScriptのランタイムにおいては、`readonly` という概念は綺麗さっぱり消え去っている。

ブラウザのJavaScriptエンジン(V8など)が実行しているのは、以下のような素のJavaScriptコードだ。

// TypeScriptの Readonly は、JSになった瞬間にただのオブジェクトに戻る
const readonlyUser = {
id: 1,
name: “Taro”,
};

// JSのランタイム上では、普通に書き換えが可能!
readonlyUser.name = “Jiro”; // エラーは起きない

「えっ、じゃあ意味ないじゃん!」って焦ったかい?
安心してほしい。TypeScriptのコンパイラが「お前、そこ書き換えようとしてるけど型違反だぞ!」ってビルド時(あるいはエディタ上)に強力にガードしてくれるから、バグを未然に防げるんだ。

ただし、「完全に実行時でもオブジェクトを凍結したい(イミュータブルを保証したい)」という場合は、TypeScriptの型システムだけでは不十分な点に注意してくれ。その場合は、JavaScriptの標準機能である `Object.freeze()` と組み合わせるのがプロの現場の作法だ。

interface Config {
readonly endpoint: string;
readonly timeout: number;
}

// 型で縛りつつ、ランタイムでも書き換えを完全封鎖する
const appConfig: Readonly = Object.freeze({
env: “production”,
timeout: 5000,
});

// 万が一、JSとして実行時に書き換えようとしても、
// strictモードであれば TypeError がスローされる
// appConfig.timeout = 10000;

—

3. 現場で役立つ!コピペで使える実践的サンプルコード

さて、ここからが本番だ。実務でよく遭遇する「ネストしたオブジェクトの罠」と、その解決策を見ていこう。

罠:`Readonly` は「浅い(Shallow)」という事実

実は、標準の `Readonly` は、直下のプロパティしか読み取り専用にしてくれない(シャロー・イミュータブル)。オブジェクトの中にさらにオブジェクトがある場合、内側のプロパティは平気で書き換えられてしまうんだ。

以下のコードを見てくれ。

interface Team {
name: string;
leader: {
name: string;
};
}

const myTeam: Readonly = {
name: “Frontend Guild”,
leader: {
name: “Taro”,
},
};

// 外側はちゃんとエラーになる
// myTeam.name = “Backend Guild”; // ❌ エラー!

// 【注意】内側のオブジェクトは書き換えられてしまう!
myTeam.leader.name = “Jiro”; // ⭕️ あれっ、通っちゃったよ……!

これに気づかずにハマる中級エンジニアが本当に多い。再帰的にすべてをイミュータブルにしたい場合は、自作のユーティリティ型(DeepReadonly)を作る必要がある。

解決策:再帰的にすべてを凍結する `DeepReadonly`

実務のボイラープレートとして、共通の型定義ファイル(`types/common.d.ts` など)にこれを仕込んでおくと、チームメンバーから神扱いされること間違いなしだ。

// 再帰的にすべてのプロパティを 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 ComplexState {
user: {
profile: {
name: string;
settings: {
darkMode: boolean;
};
};
};
}

const state: DeepReadonly = {
user: {
profile: {
name: “Alice”,
settings: {
darkMode: true,
},
},
},
};

// すべての階層で書き換えがコンパイルエラーになる!
// state.user.profile.settings.darkMode = false;
// ❌ Cannot assign to ‘darkMode’ because it is a read-only property.

この `DeepReadonly` さえあれば、どんなに複雑なReduxのステートやAPIレスポンスのツリー構造であっても、一網打尽にイミュータブルとして保護できる。

—

4. シニアからのアドバイス:いつ使うべきか?

なんでもかんでも `Readonly` や `DeepReadonly` をつければいいってもんでもない。過剰な型付けは、開発時のコード書くスピードを落とす原因(いわゆる過剰設計)になることもあるからだ。

俺がチームに推奨しているベストプラクティスは以下の通りだ。

1. 関数コンポーネントの Props
Propsは基本的に子コンポーネント側で勝手に書き換えるべきではない。そのため、`React.FC>` のように明示するか、そもそも引数の型を `Readonly` で受けるように設計すると、データフローが単方向になり、Reactの挙動が劇的に安定する。
2. 状態管理(Redux, Zustand, React Contextなど)のステート型
グローバルステートや、コンポーネントのイミュータブルな状態定義には迷わず使うべき。予期せぬミューテーションによる「再レンダリングが走らないバグ」を根絶できる。
3. APIクライアントのレスポンス型
バックエンドから受け取ったデータは「変更不能な事実(Facts)」であるべきだ。フロントエンド側で加工が必要な場合は、新しいオブジェクトをスプレッド構文などでクローンして作るべきであり、元のレスポンスを直接いじらない担保として `Readonly` が最高の防壁になる。

—

まとめ

TypeScriptの `Readonly` は、単なる「エラーを防ぐための便利機能」じゃない。
「このデータ構造のこの部分は、誰も勝手に書き換えてはいけない」という開発チーム全体の意思表示であり、堅牢なアーキテクチャを支えるための強力な契約書なんだ。

最初は少し面倒くさく感じるかもしれないけれど、一度この安全性に慣れると、普通の mutable なコードを書くのが怖くなるはずだ。

さあ、今日のプロダクトのコードを開いて、無防備に晒されているステートやPropsに `Readonly` をブチ込んでやろうぜ。何か質問があったら、いつでも俺のところへ聞きに来てくれ。ハッピー・コーディング!

コメント

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