こんにちは!TypeScriptの型システムの世界へようこそ。
日々、フロントエンドやNode.jsの開発でTypeScriptを書いていると、「オブジェクトのネスト構造の深くまで、安全にアクセスしたい!」という場面に直面しますよね。
例えば、`user.address.zip` のような文字列(ドット区切りのパス)を引数として受け取り、「そんなプロパティは存在しない!」というタイポをコンパイル時に検知させたいと思ったことはありませんか?
今回は、Template Literal Types(テンプレートリテラル型)の再帰的な活用を通じて、型レベルでパスの存在を完全検証する高度なテクニックを、一緒に紐解いていきましょう。ここをクリアすれば、TypeScriptの型システムの本質と「型パズルの楽しさ」がグッと身につきますよ。焦らず一つずつ見ていきましょうね。
—
なぜ普通の型定義ではダメなのか?
まずは、私たちが普段やりがちなコードを思い浮かべてみてください。
type User = {
id: number;
name: string;
address: {
city: string;
zip: string;
};
};
もし、特定のプロパティを取得する関数 `getProperty` を作るとしたら、どう型付けしますか?
素直に書くと、こうなりますよね。
function getProperty(obj: User, path: string) {
// 実際はここでパスを分割してループする処理が入ります
}
この `path` の型が `string` だと、`getProperty(user, “address.zuip”)` とタイポ(入力ミス)しても、TypeScriptは何も文句を言ってくれません。実行時に `undefined` になって初めて気づく……というのは、型安全を愛する私たちにとって避けたい事態です。
これを、「コンパイル時に、存在するパスしか受け付けないようにする」のが今回の目標です。
—
魔法のレシピ:Template Literal Types × 再帰
TypeScriptの Template Literal Types を使うと、文字列型を結合したり、パースしたりできます。さらに、自分自身を呼び出す「再帰(Recursion)」を組み合わせることで、ネストしたオブジェクトの深さを無限に追うことができるのです。
まずは、完成形のコード全体を見てみましょう。
// 1. 検査対象となるサンプルのデータ構造
type User = {
id: number;
name: string;
address: {
city: string;
zip: string;
geo: {
lat: number;
lng: number;
};
};
};
// 2. 再帰的にドット区切りのパス型を生成する魔法の型
type Paths
? {
[K in keyof T]: K extends string
? `${K}` | `${K}.${Paths
: never;
}[keyof T]
: never;
// 【動作確認】
// Paths
// “id” | “name” | “address” | “address.city” | “address.zip” | “address.geo” | “address.geo.lat” | …
コードの心臓部を分解して読み解こう
この `Paths
1. `T extends object`(オブジェクトの判定)
- 「もし渡された `T` がオブジェクトなら〜」という条件分岐です。プリ型(文字列や数値など)に到達したら、再帰をストップします。
2. Mapped Types (`{ [K in keyof T]: … }`)
- オブジェクトのキーを一つずつ取り出し、それぞれのキーについて処理を行います。
3. Template Literal Types (“${K}.${Paths
- ここが肝です!「親のキー `K`」と「子(ネスト先)のパス `Paths
`」をドット `.` で繋いでいます。これが再帰的に繰り返されることで、`address.geo.lat` のような深いパスが自動生成されます。
4. `[keyof T]`(ユニオン化)
- オブジェクトの各プロパティから作られた型の配列を、TypeScriptの「インデクスアクセス」の技を使って、一つの大きなユニオン型(`”id” | “name” | “address.city” …`)にまとめ上げています。
—
実践:安全な `getValue` 関数を実装する
パスの型が作れるようになったら、次はそれを使って「指定したパスに紐づく値の型」も自動で推論する関数を作ってみましょう。
// パスを受け取り、対応する値の型を逆引きする型(DeepGet)
type DeepGet
? K extends keyof T
? DeepGet
: never
: P extends keyof T
? T[P]
: never;
// 型安全な値取得関数
function getValue
const keys = path.split(‘.’);
let current: any = obj;
for (const key of keys) {
current = current[key];
}
return current;
}
使ってみる
const user: User = {
id: 1,
name: ‘Taro’,
address: {
city: ‘Tokyo’,
zip: ‘100-0001’,
geo: {
lat: 35.6812,
lng: 139.7671,
},
},
};
// 🟢 成功例:存在するパスなので正しく型推論され、値が取れる
const city = getValue(user, ‘address.city’); // 型は string
const lat = getValue(user, ‘address.geo.lat’); // 型は number
// 🔴 コンパイルエラー例:存在しないパスを指定した場合
// const error = getValue(user, ‘address.latitude’);
// ❌ Error: Argument of type ‘”address.latitude”‘ is not assignable to parameter of type ‘Paths
素晴らしいですね! `address.latitude` と打ち間違えた瞬間、TypeScriptのコンパイラが赤く波線を引いて教えてくれます。実行時エラーの温床を、コードを書いている瞬間に根絶やしにできるわけです。
—
陥りやすい文法エラーと注意点
この高度な型定義を書く際、初学者がハマりやすいポイントがいくつかあります。
1. `keyof` の対象がプリミティブになるエラー
- 再帰の終了条件を書き忘れて `T[K]` が `string` や `number` になった際、そのまま `keyof` を回そうとすると予期せぬ型エラーになります。「`T extends object` で絞り込む」というガードを絶対に忘れないようにしましょう。
2. コンパイルパフォーマンスの沼(TypeScriptの限界)
- あまりにも巨大で複雑すぎるスキーマ(何百個ものネストを持つデータ構造)に対してこの再帰的型を適用すると、TypeScriptの型チェッカー(TSServer)が重くなり、IDEの補完が遅くなる現象(Instantiation depth exceeded)が発生することがあります。実用上は、必要な深さやデータ構造の規模感を意識して設計することがプロの腕の見せ所です。
—
まとめ
今回は、Template Literal Types の再帰的活用を通じて、オブジェクトのパスの存在を型レベルで検証するテクニックを解説しました。
- Template Literal Types を使えば、文字列の形を型として表現できる。
- 再帰(Recursion)を組み合わせることで、無限の深さを持つネスト構造にも対応できる。
- これにより、「タイポをコンパイル時に防ぐ、最高にモダンで安全なAPI」が構築できる。
ここをクリアできれば、単なる「型の付け方を知っている人」から「型システムを自在に操り、バグの生まれない設計ができるアーキテクト」へと大きくステップアップできますよ。
ぜひ、実際のプロジェクトのフォーム状態管理や設定ファイルのバリデーションなどに応用してみてくださいね。それでは、次のTypeScriptの旅でお会いしましょう!