30 / 36
Как типизировать функцию get по строковому пути (lodash.get)?
Полный ответ
Полная реализация с поддержкой массивов:
TypeScript
// Шаг 1: генерация всех допустимых путей
type Path<T> = T extends ReadonlyArray<infer U>
? `${number}` | `${number}.${Path<U>}`
: T extends object
? { [K in keyof T & string]: K | `${K}.${Path<T[K]>}` }[keyof T & string]
: never;
// Шаг 2: получение типа значения по пути
type PathValue<T, P extends string> = P extends `${infer K}.${infer Rest}`
? K extends `${number}`
? T extends ReadonlyArray<infer U>
? PathValue<U, Rest>
: never
: K extends keyof T
? PathValue<T[K], Rest>
: never
: P extends `${number}`
? T extends ReadonlyArray<infer U>
? U
: never
: P extends keyof T
? T[P]
: never;
// Шаг 3: реализация get
function get<T, P extends Path<T> & string>(
obj: T,
path: P
): PathValue<T, P> | undefined {
const keys = path.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current === null || current === undefined) return undefined;
current = (current as Record<string, unknown>)[key];
}
return current as PathValue<T, P> | undefined;
}
Использование:
TypeScript
interface Company {
name: string;
ceo: {
firstName: string;
lastName: string;
contacts: {
email: string;
phones: string[];
};
};
departments: Array<{
title: string;
headcount: number;
}>;
}
const company: Company = {
name: "Acme",
ceo: {
firstName: "Alice",
lastName: "Smith",
contacts: { email: "alice@acme.com", phones: ["+1234567890"] },
},
departments: [
{ title: "Engineering", headcount: 50 },
{ title: "Marketing", headcount: 20 },
],
};
const email = get(company, "ceo.contacts.email"); // string | undefined
const name = get(company, "name"); // string | undefined
const dept = get(company, "departments.0.title"); // string | undefined
// get(company, "ceo.age"); // ошибка: "ceo.age" не Path<Company>
// get(company, "nonexistent"); // ошибка
Версия с default value:
TypeScript
function getOr<T, P extends Path<T> & string, D>(
obj: T,
path: P,
defaultValue: D
): NonNullable<PathValue<T, P>> | D {
const result = get(obj, path);
return (result ?? defaultValue) as NonNullable<PathValue<T, P>> | D;
}
const phone = getOr(company, "ceo.contacts.phones.0", "N/A"); // string
Типобезопасный set:
TypeScript
function set<T extends object, P extends Path<T> & string>(
obj: T,
path: P,
value: PathValue<T, P>
): T {
const keys = path.split(".");
const result = structuredClone(obj);
let current: Record<string, unknown> = result as Record<string, unknown>;
for (let i = 0; i < keys.length - 1; i++) {
const next = current[keys[i]];
if (typeof next === "object" && next !== null) {
const clone = Array.isArray(next) ? [...next] : { ...next };
current[keys[i]] = clone;
current = clone as Record<string, unknown>;
}
}
current[keys[keys.length - 1]] = value;
return result;
}
const updated = set(company, "ceo.firstName", "Bob"); // updated.ceo.firstName === "Bob", оригинал не изменён
Реальные кейсы
Типобезопасный i18n:
TypeScript
const translations = {
header: { title: "Welcome", subtitle: "To our app" },
buttons: { submit: "Submit", cancel: "Cancel" },
} as const;
type Translations = typeof translations;
function t<P extends Path<Translations> & string>(key: P): PathValue<Translations, P> {
return get(translations, key) as PathValue<Translations, P>;
}
const title = t("header.title"); // "Welcome" (literal type!) // t("header.nonexistent"); // ошибка компиляции
Ошибка: забыть про undefined в возвращаемом типе:
TypeScript
// ❌ Если объект пришёл из API, вложенные поля могут быть undefined
function unsafeGet<T, P extends Path<T> & string>(
obj: T,
path: P
): PathValue<T, P> {
// runtime crash, если промежуточный ключ undefined
return path.split(".").reduce((c: unknown, k) => (c as Record<string, unknown>)[k], obj) as PathValue<T, P>;
}
// ✅ Всегда возвращайте T | undefined
function safeGet<T, P extends Path<T> & string>(
obj: T,
path: P
): PathValue<T, P> | undefined {
// ...с проверкой null/undefined на каждом шаге
}
Резюме
Типобезопасный get опирается на Path<T> для автодополнения и PathValue<T, P> для вывода типа результата. Template literal types с infer разбирают строку "a.b.c" по точкам на уровне типов. Всегда учитывайте undefined в возвращаемом типе и обрабатывайте массивы через числовые индексы.