Перейти к содержимому
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 в возвращаемом типе и обрабатывайте массивы через числовые индексы.

Как типизировать функцию get по строковому пути (lodash.get)? | JScriptiser