Объекты в джаваскрипте встречаются в двух ипостасях — как структуры и как словари.
Например, 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
превращает работу с массивами через индексы в ад, требуя постоянно проверку, что значаение существует. Поэтому чаще эту настройку я вижу выключенной.