Правильная типизация Object.keys и Object.entries в TypeScript

Вы замечали, что Object.keys и Object.entries в TypeScript работают не совсем интуитивно? Они возвращают не совсем то, что можно было бы ожидать, даже с readonly-объектом.

Давайте разберёмся.

Возьмём следующий объект с константным утверждением (as const), чтобы TypeScript использовал наиболее конкретный тип выражения и сделал свойства readonly:

const data = {
  a: 'value-a',
  b: 'value-b',
  c: 'value-c',
} as const;

Можно ожидать, что Object.values вернёт литеральные значения объекта — и это действительно так:

const values = Object.values(data);
//    ^? const values: ("value-a" | "value-b" | "value-c")[]

Но что насчёт Object.keys и Object.entries?

Object.keys

При вызове Object.keys с нашим объектом возвращается string[]:

const keys = Object.keys(data);
//    ^? const keys: string[]

И это сделано намеренно! Object.keys всегда возвращает string[]:

/**
 * Возвращает имена перечисляемых строковых свойств и методов объекта.
 * @param o - Объект, содержащий свойства и методы. Это может быть как созданный вами объект, так и существующий DOM-объект.
 */
keys(o: {}): string[];

Это связано с тем, что типы в TypeScript намеренно остаются открытыми, поэтому он не всегда может гарантировать, что в вашем объекте нет дополнительных свойств, даже если он определён с as const. 😥

Возможно, ситуация изменится, когда появится Record (глубоко неизменяемая структура, похожая на объект). 🤞

Решение

К счастью, TypeScript предоставляет оператор keyof, который возвращает тип ключей заданного типа:

Предупреждение
Это работает только если объект неизменяем и не содержит лишних свойств.

// Тип дополнен `& {}`, чтобы упростить отображение (иначе показывается просто `Keys`)
type Keys = (keyof typeof data)[] & {};
//   ^? type Keys = ("a" | "b" | "c")[]

Затем можно привести результат Object.keys к нашему типу:

const typedKeys = Object.keys(data) as Keys;
//    ^? const typedKeys: ("a" | "b" | "c")[]

Или в сокращённой форме:

const typedKeys = Object.keys(data) as (keyof typeof data)[];
//    ^? const typedKeys: ("a" | "b" | "c")[]

Можно пойти дальше и создать обобщённую функцию:

function keysFromObject<T extends object>(object: T): (keyof T)[] {
  return Object.keys(object) as (keyof T)[];
}

const typedKeys = keysFromObject(data);
//    ^? const typedKeys: ("a" | "b" | "c")[]

Object.entries

То же самое касается Object.entries.

value определяется корректно, но key имеет тип string:

const entries = Object.entries(data).map(
  ([key, value]) => [key, value],
  // ^? (parameter) key: string
);

Решение

Можно использовать keyof вместе с обобщённым типом, чтобы захватить ключи объекта, а затем привести результат Object.entries:

type Entries<T> = {
  [K in keyof T]: [K, T[K]];
}[keyof T][];

const typedEntries = (Object.entries(data) as Entries<typeof data>).map(
  ([key, value]) => [key, value],
  // ^? (parameter) key: "a" | "b" | "c"
);

Также можно воспользоваться типом Entries из библиотеки type-fest:

import type { Entries } from 'type-fest';

const typedEntries = (Object.entries(data) as Entries<typeof data>).map(
  ([key, value]) => [key, value],
  // ^? (parameter) key: "a" | "b" | "c"
);

Или создать свою обобщённую функцию:

function entriesFromObject<T extends object>(object: T): Entries<T> {
  return Object.entries(object) as Entries<T>;
}

const typedEntries2 = entriesFromObject(data).map(
  ([key, value]) => [key, value],
  // ^? (parameter) key: "a" | "b" | "c"
);

Источник.