はじめに
「user.preferences.theme」のようなドット区切りのパス文字列、実務で扱ったことはありませんか?
状態管理で特定のキーにアクセスしたり、フォームライブラリでフィールド名を指定したり、i18nのキーを渡したり——こうした場面でパス文字列はよく登場します。ただし、普通に string 型で扱ってしまうとタイポしても気づけません。
この記事では、ネストしたオブジェクトのすべてのキーをドット記法のUnion型として自動生成する Path<T> 型を紹介します。
本記事は @KaraBharat氏のツイート を参考に、実務での活用方法を補足してまとめたものです。
完成形
先に完成形を見てみましょう。
type Path<T> = T extends object
? {
[K in keyof T]: K extends string ? `${K}` | `${K}.${Path<T[K]>}` : never;
}[keyof T]
: never;
これを使うと、こうなります。
interface AppState {
user: {
id: string;
preferences: {
theme: "light" | "dark";
lang: "en" | "es";
};
};
ui: {
sidebarOpen: boolean;
};
}
type AppStatePath = Path<AppState>;
// "user"
// | "ui"
// | "user.id"
// | "user.preferences"
// | "user.preferences.theme"
// | "user.preferences.lang"
// | "ui.sidebarOpen"
AppState のネスト構造をすべて辿って、ドット区切りのパスがUnion型として列挙されます。
型の仕組みを分解して理解する
一見すると複雑ですが、やっていることは3ステップです。
ステップ1: オブジェクトかどうかを判定
T extends object ? { ... } : never;
T がオブジェクト型でなければ never(=末端に到達)。string や boolean のようなプリミティブ型に対しては再帰しません。
ステップ2: 各キーに対してマッピング
[K in keyof T]: K extends string ? `${K}` | `${K}.${Path<T[K]>}` : never;
keyof T でオブジェクトの各キー K を取り出し、テンプレートリテラル型で2種類の文字列を生成します。
-
${K}— そのキー自体(例:"user") -
`${K}.${Path<T[K]>}`— そのキー+子のパスを再帰的に連結(例:"user.id","user.preferences.theme")
K extends string のガードは、keyof が string | number | symbol を返す可能性があるため、文字列キーだけに限定するためのものです。
ステップ3: Mapped Typeの値をUnionに変換
}[keyof T]
最後の [keyof T] は Indexed Access Type です。Mapped Typeで生成したオブジェクト型の全プロパティの値を取り出して、Union型にまとめます。
// イメージとしてはこう展開される
{
user: "user" | "user.id" | "user.preferences" | "user.preferences.theme" | "user.preferences.lang";
ui: "ui" | "ui.sidebarOpen";
}[keyof AppState]
// ↓ [keyof AppState] で値だけ取り出すと...
// "user" | "user.id" | "user.preferences" | "user.preferences.theme" | "user.preferences.lang"
// | "ui" | "ui.sidebarOpen"
実務での使いどころ
1. 型安全な状態アクセス関数
状態管理で特定のパスの値を取得・更新する関数に使えます。
function getByPath<T>(obj: T, path: Path<T>): unknown {
return path.split(".").reduce((acc: any, key) => acc?.[key], obj);
}
const state: AppState = {
user: { id: "1", preferences: { theme: "dark", lang: "en" } },
ui: { sidebarOpen: false },
};
getByPath(state, "user.preferences.theme"); // ✅ OK
getByPath(state, "user.preferences.color"); // ❌ コンパイルエラー!
パスをタイポすると コンパイル時に エラーになります。
2. フォームライブラリのフィールド名
React Hook Form などでネストしたフォームのフィールド名を扱う場面で、入力補完が効くようになります。
function useFormField(name: Path<FormValues>) {
// name は "user.name" | "user.email" | "address.city" | ... のようなUnion型
}
3. i18n / 翻訳キーの型安全化
翻訳ファイルのキーを型で縛ることで、存在しないキーへのアクセスを防げます。
const translations = {
header: { title: "ようこそ", subtitle: "ログインしてください" },
footer: { copyright: "© 2025" },
} as const;
type TranslationKey = Path<typeof translations>;
// "header" | "footer" | "header.title" | "header.subtitle" | "footer.copyright"
function t(key: TranslationKey): string { ... }
t("header.title"); // ✅
t("header.typo"); // ❌ コンパイルエラー
注意点
循環参照には対応していない
自分自身を参照するような型に Path<T> を適用すると、再帰が無限ループになりTypeScriptがエラーを出します。
interface TreeNode {
value: string;
children: TreeNode[]; // 循環参照
}
type TreePath = Path<TreeNode>; // ❌ Type instantiation is excessively deep
対策としては、再帰の深さを制限するパターンがあります。
type Path<T, Depth extends number[] = []> = Depth["length"] extends 5
? never // 5階層で打ち止め
: T extends object
? {
[K in keyof T]: K extends string
? `${K}` | `${K}.${Path<T[K], [...Depth, 0]>}`
: never;
}[keyof T]
: never;
配列のインデックスは含まれない
この実装では配列のインデックス(items.0.name のような数値パス)は生成されません。配列要素へのパスが必要な場合は追加の工夫が必要です。
まとめ
Path<T> 型は、TypeScriptの以下のテクニックを組み合わせた応用例です。
-
Conditional Types (
T extends object ? ... : never) -
Mapped Types (
[K in keyof T]) -
Template Literal Types (
`${K}.${Path<T[K]>}`) -
Indexed Access Types (
{ ... }[keyof T]) -
再帰型 (
Path<T[K]>で自分自身を呼び出す)
ドット記法のパスを string で扱っている箇所があれば、この型を導入するだけでタイポや存在しないキーへのアクセスがコンパイル時に検出できるようになります。ぜひ試してみてください。