Повышение уровня TypeScript с помощью типов Record

Источник: «Level up your TypeScript with Record types»
Исследуем тип Record в TypeScript, чтобы лучше понять, как он работает. Также рассмотрим примеры его использования, например, использование с дженериками.

В информатике запись — это структура данных, содержащая набор полей, возможно, разных типов. В TypeScript тип Record просто позволяет нам определять словари, также называемые парами ключ/значение, с фиксированным типом для ключей и фиксированным типом для значений.

Другими словами, тип Record позволяет определить тип словаря, то есть имена и типы его ключей. В этой статье мы рассмотрим тип Record в TypeScript, чтобы лучше понять, что это такое и как он работает. Также рассмотрим, как использовать его для обработки случаев перечисления, как использовать его с дженериками для понимания свойств возвращаемого значения при написании многократно используемого кода.

В чем разница между записью и кортежем

Тип Record в TypeScript поначалу может показаться несколько нелогичным. В TypeScript записи имеют фиксированное количество членов (т.е. фиксированное количество полей), и эти члены обычно идентифицируются по имени. Это основное отличие записей от кортежей.

Кортежи представляют собой группы упорядоченных элементов, в которых поля идентифицируются по их позиции в определении кортежа. Поля в записях, напротив, имеют имена. Их позиция не имеет значения, поскольку мы можем использовать их имя для ссылки на них.

При этом тип Record в TypeScript поначалу может показаться непривычным. Вот официальное определение из документации: Record<Keys, Type> строит объектный тип, ключами свойств которого являются Keys, а значениями свойств — Type. Эта утилита может быть использована для сопоставления свойств одного типа другому типу.

Рассмотрим пример, чтобы лучше понять, как можно использовать тип TypeScript Record.

Реализация типа Record

Сила типа Record в том, что с его помощью можно моделировать словари с фиксированным числом ключей. Например, мы можем использовать тип Record для создания модели университетских курсов:

type Course = "Computer Science" | "Mathematics" | "Literature"

interface CourseInfo {
professor: string
cfu: number
}

const courses: Record<Course, CourseInfo> = {
"Computer Science": {
professor: "Mary Jane",
cfu: 12
},
"Mathematics": {
professor: "John Doe",
cfu: 12
},
"Literature": {
professor: "Frank Purple",
cfu: 12
}
}

В данном примере мы определили тип Course, который будет перечислять названия занятий. И тип CourseInfo, который будет содержать некоторые общие сведения о курсах. Затем мы использовали тип Record для сопоставления каждого курса с его CourseInfo.

Пока все хорошо; все это выглядит как довольно простой словарь. Настоящая сила типа Record заключается в том, что TypeScript обнаружит, если мы пропустили Course.

Идентификация недостающих свойств

Допустим, мы не включили запись для свойства Literature; при компиляции мы получим следующую ошибку: Свойство Literature отсутствует в типе { "Computer Science": { professor: string; cfu: number; }; Mathematics: { professor: string; cfu: number; }; }; }, но обязательно в типе Record<Course, CourseInfo>.

В этом примере TypeScript явно говорит нам, что Literature отсутствует.

Идентификация неопределённых свойств

TypeScript также обнаружит, если мы добавим записи для значений, которые не определены в Course. Допустим, мы добавили ещё одну запись в Course для класса History. Поскольку мы не включили History как тип Course, то получим следующую ошибку компиляции: Объектный литерал может указывать только известные свойства, а History не существует в типе Record<Course, CourseInfo>.

Доступ к данным Record

Мы можем получить доступ к данным, относящимся к каждому Course, как и к любому другому словарю:

Приведённое выше утверждение выводит следующий результат:

{ "teacher": "Frank Purple", "cfu": 12 }

Пример использования: Обеспечение исчерпывающей обработки case

При написании современных приложений часто возникает необходимость запускать различную логику на основе некоторого дискриминирующего значения. Идеальным примером является паттерн проектирования фабрика, когда мы создаём экземпляры различных объектов на основе некоторых входных данных. В этом сценарии обработка всех случаев имеет первостепенное значение.

Самым простым (и в чем-то наивным) решением было бы использование конструкции switch для обработки всех case:

Однако если мы добавим в Discriminator новый случай, то благодаря ветви default TypeScript не сообщит нам, что мы не смогли обработать новый случай в функции-фабрике. Без ветви default этого бы не произошло; вместо этого TypeScript обнаружил бы, что в Discriminator было добавлено новое значение.

Мы можем использовать возможности типа Record, чтобы исправить это:

 string> = {
1: () => "1",
2: () => "2",
3: () => "3"
}
return factories[d]()
}

console.log(factory(1))

Новая функция factory просто определяет Record, соответствующую Discriminator, с помощью специальной функции инициализации, которая не вводит никаких аргументов и возвращает строку. Затем фабрика просто получает нужную функцию, основанную на d: Discriminator, и возвращает строку, вызывая полученную функцию. Если теперь мы добавим в Discriminator больше элементов, то тип Record обеспечит обнаружение TypeScript пропущенных case в фабриках.

Пример использования: Обеспечение проверки типов в приложениях, использующих дженерики

Дженерики позволяют писать код, абстрагированный от реальных типов. Например, Record<K, V> — это дженерик. При его использовании мы должны выбрать два реальных типа: один для ключей (K) и один для значений (V).

В современном программировании дженерики чрезвычайно полезны, поскольку позволяют писать многократно используемый код. Код для выполнения HTTP-вызовов или запросов к базе данных обычно является дженериком по типу возвращаемого значения. Это очень хорошо, но за это приходится расплачиваться, так как нам трудно узнать реальные свойства возвращаемого значения.

Это можно решить, используя тип Record:

> {
constructor(
public readonly properties: Record<
keyof Properties,
Properties[keyof Properties]
>

) {}
}

Result является несколько сложным. В данном примере мы объявляем его как общий тип, где параметр типа, Properties, по умолчанию принимает значение Record<string, any>.

Использование any может показаться некрасивым, но на самом деле в этом есть смысл. Как мы увидим через некоторое время, в Record имена свойств будут сопоставляться со значениями свойств, поэтому мы не можем заранее знать тип свойств. Более того, чтобы сделать его максимально многоразовым, нам придётся использовать самый абстрактный тип, который есть в TypeScript, — any, действительно!

В конструкторе используется синтаксический сахар TypeScript для определения свойства, доступного только для чтения, которое мы метко назвали properties. Обратите внимание на определение типа Record:

Теперь, когда мы определили наш основной тип-обёртку, мы можем поэкспериментировать с ним. Следующий пример очень прост, но он демонстрирует, как можно использовать Result для того, чтобы TypeScript проверял свойства типа дженерик:

>({
title: "Literature",
professor: "Mary Jane",
cfu: 12
})

console.log(course.properties.title)
//console.log(course.properties.students) <- это не компилируется!

В приведённом выше коде мы определяем интерфейс CourseInfo, который выглядит аналогично тому, что мы видели ранее. Он просто моделирует основную информацию, которую мы хотим хранить и запрашивать: название класса, имя professor и количество кредитов.

Далее мы смоделируем создание course. Это просто буквальное значение, но можно представить его как результат запроса к базе данных или HTTP-вызова.

Обратите внимание, что мы можем обращаться к свойствам course безопасным для типов образом. Когда мы ссылаемся на существующее свойство, например, title, оно компилируется и работает, как и ожидалось. Когда мы пытаемся получить доступ к несуществующему свойству, например students, TypeScript обнаруживает, что это свойство отсутствует в объявлении CourseInfo, и вызов не компилируется.

Это мощная возможность, которую мы можем использовать в своём коде, чтобы убедиться, что значения, получаемые из внешних источников, соответствуют ожидаемому набору свойств. Обратите внимание, что если бы course имел больше свойств, чем те, которые определены в CourseInfo, мы все равно могли бы получить к ним доступ. Другими словами, следующий фрагмент будет работать:

>({
title: "Literature",
professor: "Mary Jane",
cfu: 12,
webpage: "https://..."
})

console.log(course.properties.webpage)

Заключение

В этой статье мы рассмотрели один из встроенных типов TypeScript — Record<K, V>. Мы рассмотрели основные варианты использования типа Record и изучили его поведение. Затем мы рассмотрели два наиболее ярких случая использования типа Record.

В первом примере мы исследовали использование типа Record для того, чтобы TypeScript гарантировал обработку случаев/case перечисления. Во втором примере мы исследовали принудительную проверку типов свойств произвольного объекта в приложении с типами дженерик.

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

Дополнительные материалы

Предыдущая Статья

PHP 8.3: Вопросы безопасности при разборе пользовательских INI-строк и файлов

Следующая Статья

Глубокое погружение в Laravel Folio