【入門編】関数型における「DeepReadonly」を引数に適用し不変性を保証する設計 – TypeScript コア・型システムの基礎解析バイブル

こんにちは!フロントエンドからバックエンドまで、TypeScriptの荒波を一緒に航海していく先輩エンジニアです。

今回は、TypeScriptの型システムにおける「奥の院」とも言えるテーマ、「再帰的なReadonly(DeepReadonly)による不変性の保証」についてお話しします。

「オブジェクトのプロパティを書き換えられたくない!」と思ったとき、標準の `Readonly` を使ったことはありますよね? でも、ネストされた(入れ子になった)オブジェクトの奥深くまでは、実は保護しきれていない……という罠にハマったことはありませんか?

ここをクリアすれば、あなたの書くコードの安全性と表現力は一段と跳ね上がります。さあ、一緒にTypeScriptの型パズルの世界を紐解いていきましょう!

—

1. なぜ通常の `Readonly` では物足りないのか?

TypeScriptには、オブジェクトのプロパティをすべて読み取り専用にするユーティリティ型 `Readonly` が標準で用意されていますよね。まずはここからおさらいしましょう。

type User = {
name: string;
age: number;
};

// 通常の Readonly
type ReadonlyUser = Readonly;

const user: ReadonlyUser = { name: “Taro”, age: 25 };

// これはエラーになる(意図通り!)
// user.age = 26;

ここまではバッチリですね。では、次のような「ネストされた(入れ子構造の)オブジェクト」だったらどうでしょうか?

type Company = {
name: string;
ceo: {
name: string;
address: {
city: string;
};
};
};

type ReadonlyCompany = Readonly;

const company: ReadonlyCompany = {
name: “TechCorp”,
ceo: {
name: “Jiro”,
address: {
city: “Tokyo”,
},
},
};

// ① トップレベルはしっかり守られる
// company.name = “NewCorp”; // ❌ エラー!

// ② しかし、奥深く(ネストされたプロパティ)は…?
company.ceo.address.city = “Osaka”; // ◯ なんと、スルスル書き換えられてしまう!

おっと……! `Readonly` を使ったにもかかわらず、一番奥の `city` は書き換えができてしまいました。
なぜこんなことが起きるのでしょうか?

型の評価が「表面(シャロー)」で止まる理由

標準の `Readonly` は、以下のようなシンプルな定義になっています。

type Readonly = {
readonly [K in keyof T]: T[K];
};

これは、「指定されたオブジェクト `T` の直下のプロパティに `readonly` 修飾子をつける」というもの。つまり、お部屋の玄関の鍵は閉めたけれど、中の部屋の扉は開けっ放し状態なんです。

これを根本から解決するのが、今回学ぶ「DeepReadonly(再帰的Readonly)」です。

—

2. 救世主:「DeepReadonly」の実装と仕組み

ネストされたオブジェクトの「すべての階層」を自動的に読み取り専用にするには、「型における再帰(リダクション)」を使います。

まずは、その魔法のようなコードを見てみてください。

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;

「うわっ、なんだか難しそうな記号がたくさん出てきたぞ……」と思いましたか?
大丈夫、一つひとつ分解して優しく解説しますね。ここを理解できれば、TypeScriptの型システムをかなり手懐けたと言えますよ!

コードの分解解説

1. `T extends Function ? T : …`

  • もし型 `T` が関数だった場合は、そのまま返します(関数をReadonlyにしようとすると壊れることがあるため)。

2. `T extends Map / Set …`

  • JavaScriptの標準コレクションである `Map` や `Set` も、中身の要素まで不変にしたいので、それぞれ `ReadonlyMap` や `ReadonlySet` に変換しつつ、中身に対して再度 `DeepReadonly` を適用しています。

3. `T extends object ? { readonly [K in keyof T]: DeepReadonly } : T;`

  • ここが一番重要です!もし型 `T` がオブジェクト(配列も含みます)なら、すべてのプロパティ `K` に対して `readonly` を付与します。
  • そして、その値の型 `T[K]` に対してもう一度 `DeepReadonly` を呼び出す(再帰する)のです!これにより、何階層ネストしていようとも、一番奥のプリミティブ型(stringやnumberなど)に到達するまで `readonly` が伝播します。

—

3. 実践!DeepReadonlyで不変性を完全に支配する

先ほどの `Company` の例に、この `DeepReadonly` を適用してみましょう。

type DeepReadonly = T extends Function
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly }
: T;

type Company = {
name: string;
ceo: {
name: string;
address: {
city: string;
};
};
branches: string[]; // 配列もあるよ
};

type SafeCompany = DeepReadonly;

const myCompany: SafeCompany = {
name: “TechCorp”,
ceo: {
name: “Jiro”,
address: {
city: “Tokyo”,
},
},
branches: [“Tokyo”, “Osaka”],
};

// 試してみましょう!
// myCompany.name = “HackCorp”; // ❌ エラー!
// myCompany.ceo.name = “Saburo”; // ❌ エラー!
// myCompany.ceo.address.city = “Kyoto”; // ❌ エラー!完全に守られている!
// myCompany.branches.push(“Fukuoka”); // ❌ エラー!配列の要素追加も防げます!

どうですか? トップレベルから、何段階もネストしたオブジェクト、さらには配列のメソッド(`push` など)に至るまで、すべての改変がコンパイル時にブロックされるようになりました。

実行時ではなく、コードを書いているまさにその瞬間にエディタが赤く波線を引いて教えてくれる。これがTypeScriptの型システムの真骨頂です。

—

4. 陥りやすい文法エラーと注意点

実務でこのような再帰型を使う際、初心者がハマりがちなポイントをいくつかシェアしておきますね。

① プリミティブ型(string, number等)をそのまま渡した場合

`DeepReadonly` のようなケースです。上記の条件分岐の最後の `: T` があるおかげで、プリミティブ型はそのまま返されるためエラーにはなりませんが、「オブジェクトじゃないのにDeepReadonlyを使おうとして変な挙動になった」と勘違いしないように注意しましょう。

② サードパーティ製の複雑なクラスやDOM要素への適用

Reactの `JSX.Element` や、DOMの `HTMLElement` などの複雑なクラスインスタンスに対して `DeepReadonly` を適用すると、内部のプライベートプロパティやメソッドの型まで書き換えようとして、TypeScriptコンパイラが悲鳴を上げることがあります(あるいは膨大な型エラーが発生します)。

  • 原則: APIから取得したJSONデータや、Redux/Zustandなどの状態管理における「プレーンなオブジェクト(DTO)」に対して使うのが最も効果的です。

—

まとめ

今回は、関数型における「DeepReadonly」をテーマに、ネストされたオブジェクトの不変性をコンパイル時に保証する方法を解説しました。

  • 通常の `Readonly` は表面(トップレベル)しか守ってくれない。
  • ネストされた構造を守るには、条件分岐と自分自身を呼び出す「再帰型(Recursive Types)」のテクニックが必要。
  • これにより、意図しないデータの書き換えをコンパイルエラーとして検出し、堅牢なアプリケーションを構築できる。

ここをクリアできれば、TypeScriptの型推論や条件付き型の仕組みがグッと身近に感じられたはずです。複雑なデータ構造を扱うフロントエンド開発や、Node.jsでのドメインモデル設計において、必ずあなたの武器になりますよ。

それでは、次回の記事もお楽しみに!バッチリマスターしていきましょう!

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