Дженерик Record

Объекты в джаваскрипте встречаются в двух ипостасях — как структуры и как словари.

Например, Person — это структура с фиксироваными полями name, age и gender:

interface Person { name: string; age: number; gender: "male" | "female"; }

Этот вариант мы рассматривали в первом разделе.

А бывает, что объект исползуется как словарь. Например, для каждого юзера храним количество его очков.

const user2score = { "alex": 3, "max": 8, "kate": 5, };

Если в массиве хранятся только значения, то у объекта есть еще и ключи. Поэтому у дженерика Record, задающего объект, два типа-аргумента. Например, переменная user2score описывается как Record<string, number>. Первый тип string — это тип ключа, а второй тип number — тип значения.

Возможно, у вас возникнет вопрос: а зачем указывать тип ключа, если он всегда string?

Во-первых, в джаваскрипте ключом может быть еще и символ:

const symbol2description: Record<symbol, string> = { [Symbol.iterator]: "Позволяет перебирать объект циклом for of", [Symbol.toPrimitive]: "Описывает как объект приводится к примитиву", };

Во-вторых, в тайпскрипте возможно указать, что ключом является число:

const ageOfConsent2countries: Record<number, string[]> = { 16: ["Russia", "Switzerland", "Norway"], 17: ["Ireland"], 18: ["France", "Turkey"], };

Разумеется, по факту ключом там будет строка. Object.keys(ageOfConsent2countries) вернет массив строк ["16", "17", "18"].

В-третьих, можно указать, что ключом является какой-то тип, являющийся подмножеством типа string.

Например, нам нужно создать объект ToDo-листа, в котором мы для каждого из трех статусов — To Do, In Progress, Done — храним список задач с таким статусом:

const status2tasks = { toDo: ["TaskA", "TaskB"], inProgress: [], done: ["TaskC"], }

Как мы его опишем? Например, можно использовать интрефейс, в котором мы опишем каждый статус как отдельный ключ:

interface TodoDict { toDo: string[]; inProgress: string[]; done: string[]; }

Это рабочий вариант, так можно написать. И это будет правильною. Но все-таки отличие от интерфейса Person в том, что у TodoDict значения для всех ключей всегда одинаковые: string[].

Тогда мы можем вынести тип ключей в отдельный тип (union type), а потом использовать его в интерфейсе. Так получится не дублировать тип значения.

type TaskStatus = "toDo" | "inProgress" | "done"; interface TodoDict { [key in TaskStatus]: string[]; }

В данном примере для нашей задачи есть более простой способ описать такой тип — с использованием дженерика Record.

type TodoDict = Record<TaskStatus, string[]>; const status2tasks: TodoDict = { toDo: ["TaskA", "TaskB"], inProgress: [], done: ["TaskC"], };

На практике даже нет смысла в создании отдельного типа TodoDict, можно сразу использовать Record.

const status2tasks: Record<TaskStatus, string[]> = { toDo: ["TaskA", "TaskB"], inProgress: [], done: ["TaskC"], };

Запись Record<TaskStatus, string[]> требует, чтобы у объекта обязательно были указаны все ключи из типа TaskStatus. Если какой-то ключ не указать, будет ошибка. Если указать лишний ключ, тоже будеш ошибка.

Но что если мы, например, не хотим хранить пустые массивы? Например, в текущем примере не будет поля "inProgress". А вообще отсутствовать могут любые поля (и даже все сразу). Как описать тип такого объекта?

const status2tasks = { toDo: ["TaskA", "TaskB"], done: ["TaskC"], };

Как вариант, мы снова можем использовать interface. Как мы знаем, необязательное поле мы можем пометить знаком ?.

interface TodoDict { toDo?: string[]; inProgress?: string[]; done?: string[]; }

Или даже так:

interface TodoDict { [key in TaskStatus]?: string[]; }

Но еще лучше развить идею с Record, используя еще один дженерик — Partial. Он позволяет указать, что все поля являются необязательными. Тогда при обращении к какому-то полю тайпскрипт будет говорить, что там ожидается массив строк (если поле есть) или undefined (если поля нет).

type TaskStatus = "toDo" | "inProgress" | "done"; const status2tasks: Partial<Record<TaskStatus, string[]>> = { toDo: ["TaskA", "TaskB"], done: ["TaskC"], }; status2tasks.inProgress // string[] | undefined status2tasks.done // string[] | undefined status2tasks.toDo // string[] | undefined

Вернемся к примеру, где ключом являлась обычная строка

const user2score: Record<string, number> = { "alex": 3, "max": 8, "kate": 5, };

Еели мы обратимся к любому ключу, мы получим число.

user2score["alex"] // number user2score["tim"] // number

Но ведь это неправильно! Значение по ключу "tim" должно быть типа undefined.

Есть два варианта это исправить. Можно использовать тип Partial<Record<string, number>>. А можно в tsconfig включить флаг noUncheckedIndexedAccess, который у обычных Record всегда будет выводить тип значения, добавляя undefined.

user2score["alex"] // number | undefined user2score["tim"] // number | undefined

В каком-то смысле это более логичное поведение для Record. При использовании в качестве ключа union type мы контролируем, что все ключи обязаны находиться в объекте. Значит, и значение там тоже будет присутствовать. Но когда мы указываем вообще string, это же не значит, что в объект добавлены все возможные строки. А значит, там спокойно может встретиться undefined в качестве значения.

Настройка noUncheckedIndexedAccess добавляет аналогичное поведение и для массивов, что тоже логично.

const primeNumbers: number[] = [2, 3, 5, 7, 11]; primeNumbers[0] // number | undefined primeNumbers[100] // number | undefined

С другой стороны noUncheckedIndexedAccess превращает работу с массивами через индексы в ад, требуя постоянно проверку, что значаение существует. Поэтому чаще эту настройку я вижу выключенной.

Задачи на TypeScript

Теория по TypeScript