29 / 36
Как создать тип для путей вложенного объекта (Path<T>)?
Полный ответ
Базовая реализация:
TypeScript
type Path<T, Prefix extends string = ""> = T extends object
? {
[K in keyof T & string]: Prefix extends ""
? K | Path<T[K], K>
: `${Prefix}.${K}` | Path<T[K], `${Prefix}.${K}`>;
}[keyof T & string]
: never;
interface Settings {
user: {
name: string;
address: {
city: string;
zip: string;
};
};
theme: string;
}
type SettingsPath = Path<Settings>;
// "user" | "user.name" | "user.address" | "user.address.city" | "user.address.zip" | "theme"
Более чистая реализация через два шага:
TypeScript
type Path<T> = T extends object
? {
[K in keyof T & string]: K | `${K}.${Path<T[K]>}`;
}[keyof T & string]
: never;
type SettingsPath = Path<Settings>;
// "user" | "theme" | "user.name" | "user.address" | "user.address.city" | "user.address.zip"
Получение типа значения по пути:
TypeScript
type PathValue<T, P extends string> = P extends `${infer K}.${infer Rest}`
? K extends keyof T
? PathValue<T[K], Rest>
: never
: P extends keyof T
? T[P]
: never;
type Test1 = PathValue<Settings, "user.name">; // string
type Test2 = PathValue<Settings, "user.address.city">; // string
type Test3 = PathValue<Settings, "theme">; // string
type Test4 = PathValue<Settings, "user.address">; // { city: string; zip: string }
Типобезопасный getter:
TypeScript
function get<T, P extends Path<T> & string>(
obj: T,
path: P
): PathValue<T, P> {
return path.split(".").reduce(
(current: unknown, key) => (current as Record<string, unknown>)[key],
obj
) as PathValue<T, P>;
}
const settings: Settings = {
user: { name: "Alice", address: { city: "Moscow", zip: "101000" } },
theme: "dark",
};
const name = get(settings, "user.name"); // string
const city = get(settings, "user.address.city"); // string
// get(settings, "user.nonexistent"); // ошибка компиляции
Ограничение глубины рекурсии (для больших объектов):
TypeScript
type Path<T, Depth extends unknown[] = []> = Depth["length"] extends 5
? never // ограничиваем глубину 5 уровнями
: T extends object
? {
[K in keyof T & string]: K | `${K}.${Path<T[K], [...Depth, unknown]>}`;
}[keyof T & string]
: never;
Реальные кейсы
Типобезопасные формы:
TypeScript
interface FormData {
personal: { firstName: string; lastName: string };
contact: { email: string; phone: string };
}
function setField<P extends Path<FormData> & string>(
form: FormData,
path: P,
value: PathValue<FormData, P>
): void {
// установка значения по пути
const keys = path.split(".");
let target: Record<string, unknown> = form;
for (let i = 0; i < keys.length - 1; i++) {
target = target[keys[i]] as Record<string, unknown>;
}
target[keys[keys.length - 1]] = value;
}
setField(form, "personal.firstName", "Bob"); // OK
// setField(form, "personal.firstName", 42); // ошибка: number не string
// setField(form, "personal.middleName", "X"); // ошибка: путь не существует
Ошибка: рекурсия без ограничения на массивах:
TypeScript
// ❌ Массивы дают бесконечные числовые ключи
interface Data {
items: { name: string }[];
}
// Path<Data> попытается развернуть number -> ...
// ✅ Исключайте массивы или обрабатывайте их отдельно
type Path<T> = T extends (infer U)[]
? `${number}` | `${number}.${Path<U>}`
: T extends object
? { [K in keyof T & string]: K | `${K}.${Path<T[K]>}` }[keyof T & string]
: never;
Резюме
Path<T> — мощный рекурсивный тип на основе template literal types. В паре с PathValue<T, P> он позволяет создать полностью типобезопасные getter/setter по строковому пути. Ограничивайте глубину рекурсии и обрабатывайте массивы отдельно, чтобы не перегружать компилятор.