Сопоставленные типы, условные типы и многое другое

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

Во многих языках, таких как C# и Java, концепция обобщений существует уже давно.

Обобщения — это инструменты, используемые для создания повторно используемого кода; они позволяют нам создавать код, который может работать с несколькими типами.

Что такое дженерики в TypeScript?

Обобщения в TypeScript основаны на той же идее, что и другие языки.

Это способ создания типов из других типов, что позволяет нам параметризовать типы.

Хватит громких слов, давайте разберемся, почему дженерики — полезные инструменты, на конкретном примере.

Давайте создадим функцию, которая возвращает первый элемент массива.

Первый вариант использования использует массив number:

Прошло несколько дней, и мы очень довольны нашей новой функцией head, но теперь нам нужно получить первый элемент из массива строк.

Хорошо, это не проблема. Давайте добавим поддержку строк.

Во-первых, мы пытаемся использовать тип union.

Мы чувствуем, что здесь что-то не так.

При использовании массива numbers в качестве входных данных для head мы знаем, что нет способа вернуть строку.

Мы можем попробовать использовать any:

Но мы не получаем информации о типе для firstNum . Мы знаем, что это может быть только number или undefined, но при использовании any мы теряем эту информацию.

На помощь приходят дженерики TypeScript!

head — функция многократного использования. Единственное, что ему нужно знать, это то, что ему дан массив.

И возвращаемый тип будет типом элемента, который находится в массиве, или undefined.

Чтобы переписать head типов, давайте разберемся, что такое параметры типа.

Параметры типа являются заполнителями для определенного типа.

  • <T> В треугольных скобках справа от liftArray мы определили «параметры типа». Мы можем определить один или несколько, разделенных запятой <T,E>
  • t:T Здесь мы указываем тип t, который является входом нашей функции. Указываем параметр типа T
  • :T[] Мы указываем, что возвращаемое значение является массивом параметра типа T

Посмотрим, когда T получит свое конкретное значение:

В этом случае T получает тип number, когда мы вызываем liftArray<number>.. На этом этапе мы создаем экземпляр универсального типа.

Обычно вы не видите, как вызывающая функция указывает тип, как мы.

Вместо этого вы видите liftArray(5), потому что TypeScript может вывести тип из ввода функции.

Вернемся к нашему примеру создания head.

Мы добавляем параметр типа в функцию head, чтобы сделать ее универсальной.

Мы добавили T в качестве параметра типа к head<T>, затем мы использовали этот параметр для ввода arr: T[], который указывает, что это массив типа T.

И мы указали возвращаемый тип T | undefined .

В строках с 5 по 8 мы можем увидеть магию дженериков в действии.

Мы можем отправить любой тип массива в head, и мы получим элемент того же типа, что и элемент массива.

Использование numArr типа number[] TypeScript делает вывод, что T = number и мы получаем обратно number или undefined .

То же самое работает для string с использованием strArr .

Generic позволил нам создать многоразовый и типобезопасный код.

Задача — создать общую функцию карты

Создайте универсальную функцию, которая получает массив и функцию сопоставления.

  • Сделайте так, чтобы код скомпилировался.
  • map тип возвращаемого значения должен быть таким же, как у элемента массива.
  • Не используйте Array.prototype.map() .

Нажмите на ссылку ниже, чтобы начать взлом:



Проверьте свое решение здесь:



Параметры универсального типа полезны, но, как и во многих других областях программирования, добавление ограничений может сделать что-то лучше.

Общие ограничения

Ограничения дженериков — это способ ограничить параметры нашего типа и уменьшить количество возможных типов, которыми они могут быть.

Чтобы объяснить, почему нам нужно ограничивать наши типы, я воспользуюсь примером:

Мы запускаем сайт электронной коммерции для зоомагазина.

Мы хотим создать функцию, которая возвращает общую сумму для оплаты при оформлении заказа.

В нашем магазине есть много типов предметов, и каждый из них представлен с помощью объекта.

Например, корм для собак представлен:

type DogFood = {
  mainIngredient: 'salmon' | 'beef' | 'chicken';
  size: 'big' | 'small' | 'medium';
  price: number;
};

Каждый из этих объектов имеет множество различных свойств, но все они имеют свойство price:number.

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

К сожалению, этот код не скомпилируется.

Вместо этого мы получим следующую ошибку компилятора:

Property 'price' does not exist on type 'T'

TypeScript никак не может узнать, что cartItem имеет свойство price.

Без ограничений на T он в основном функционирует как any внутри calcCartTotal.

Важно отметить, что в примере с функцией head мы не добавили constrained к параметру type, а использовали его для ввода и вывода.

В этом случае параметры типа помогли нам создать «связь» между типом ввода и выводом функции.

Используйте параметр типа как минимум дважды или добавьте к ним ограничение.

Мы хотим, чтобы наш calcCartTotal мог получить cart со многими различными типами элементов, а OfCourse был безопасным для типов.

Мы добавим ограничения к параметру типа.

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

Чтобы применить ограничения к параметрам типа, мы используем ключевое слово extends.

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

В этом случае параметр типа может быть любым object, имеющим свойство length номера типа.

Тип перед ключевым словом extends является более конкретным, чем тип после него.

Можно запомнить это так: в объектно-ориентированном программировании класс, расширяющий другой класс, является более конкретным.

Вернемся к нашему примеру с зоомагазином (покупатели расстраиваются!).

Вот типы наших различных товаров в корзине:

Мы создали интерфейс cartItem с единственным свойством price:number.

Все остальные интерфейсы расширяют его.

Интерфейс extends означает, что интерфейс получит все свойства интерфейса, который он расширяет.

Таким образом, Leash будет иметь price из CartItem и size и color из собственного объявления.

Мы можем остановиться на секунду и попытаться реализовать calcCartTotal с помощью объединения. Вот код:

Этот код прекрасно компилируется и работает, но есть небольшая проблема, на следующей неделе мы получим новую поставку Catnip.

Давайте попробуем добавить его в нашу корзину с помощью следующего кода:

Этот код ломается!

Type 'Catnip' is not assignable to type 'Leash | SqueakyToy'.
  Type 'Catnip' is missing the following properties from type 'SqueakyToy': shape, color

Поскольку мы не указали его в типе cart для компиляции, мы должны добавить его в объединение следующим образом:

Однако мы не хотим обновлять тип cart каждый раз, когда у нас появляется новый товар в магазине. Это большая проблема!

К счастью, добавление ограничений к нашим параметрам типа решит эту проблему.

Давайте создадим общую ограниченную версию calcCartTotal, используя следующий код:

T extends CartItem, что означает, что параметр типа T должен быть типом объекта со свойством price типа number. Конечно, он может иметь гораздо больше свойств.

Давайте посмотрим на использование нашей новой функции ниже:

В строке 33 мы не видим ошибок компиляции, поскольку leash и squeakyToy имеют свойство price типа number, которое удовлетворяет ограничениям, которые мы наложили на T.

Мы создали функцию, которая может принимать массив любых CartItem, даже предметов, которых еще нет в магазине. Это означает, что нам больше не придется менять тип cart.

А еще мы очень порадовали наших четвероногих друзей.

Условные типы

Иногда мы хотим задать вопросы о наших типах, и для этой цели мы можем использовать условные типы.

При использовании условных типов вам не обязательно использовать обобщения TypeScript, но вместе они очень эффективны.

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

Условные типы имеют тот же синтаксис, что и тернарный оператор в JS.

Для значения JS мы используем следующий псевдокод:

condition ? exprIfTrue : exprIfFalse

Вот пример реального кода:

const lunch = isVeryHungry ? 'Burger' : 'Salad';

По сути, сокращение для if-else всегда разрешается в значение.

А с типами это выглядит так:

Мы видим условные типы DogOrCat , в строках 11–13 мы видим их использование.

Если мы предоставим значение типа 'dog', мы получим тип Dog. В противном случае мы получим тип Cat.

Условные типы имеют следующий синтаксис:

SomeType extends OtherType ? TypeIfTrue : TypeIfFalse;

Давайте разберем это:

  1. SomeType extends OtherType: условие, extends проверяет, что someType по крайней мере так же конкретно, если не более конкретно, чем OtherType .
  2. TypeIfTrue — тип выражения, если условие истинно.
  3. TypeIfFalse — тип выражения, если условие ложно.

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

Что такое тип result1 и result2?

Подумайте об этом на секунду, прежде чем прокрутить вниз!

.

.

.

.

.

.

Барабанная дробь, пожалуйста, Дум-Дум-Дум, ответы таковы:

type result1 = true
type result2 = false

Не волнуйтесь, если вы ошиблись, это немного сбивает с толку в начале.

Полезно думать об этом, предполагая, что все возможные группы типов в левой части extends содержатся в группе типов в правой части.

Группа справа — единственная 's', которая содержится в группе всех возможных строк, поэтому type result1 = true.

Группа всех строк не содержится в группе, состоящей только из 's', поэтому type result2 = false.

Теперь, когда мы знаем, как использовать условные операторы, давайте посмотрим, почему они полезны в сочетании с дженериками TypeScript.

Извлечь и исключить

Типы Extract и Exclude используются для получения подтипа из основного типа.

Что делает Extract?

Extract<Type,Union>

Extract получает из универсального Type все члены объединения, которые могут быть назначены Union .

Не так ясно? Итак, давайте использовать пример:

Extract «возьмет» все типы из Width, соответствующие string или { type: string }.

Это значит, что:

type KeyWordWidth = 'auto' | 'min-content' | 'max-content' | { type: string }

Exclude — полная противоположность: удаляется все, что соответствует Union.

Время сборки

Теперь, когда мы понимаем, что делают Extract и Exclude, давайте создадим их сами, используя условные типы.

Во-первых, нам нужно будет принять два параметра типа, T и U. Вот как с ними работать:

type Extract<T,U>

Затем нам нужно понять, как extends работает с типом объединения.

При использовании extends для типа объединения выполняется то же самое, что и обычно, только для каждой части объединения.

"a" | 5 extends string | number

Это то же самое, что:

"a" extends string OR
"a" extends number OR
 5  extends string OR
 5  extends number

При использовании типа never в объединении с другим типом never исчезнет. Например, number | never — это просто number.

Собираем все вместе и получаем следующее:

При использовании Type extends Union extends делает всю работу за нас и проверяет каждую комбинацию, как показано ниже:

Type = T1 | T2 | T3 ...
Union = U1 | U2 | U3 ...
T1 extends U1 ? 
T1 extends U2 ?
T1 extends U3 ?
T2 extends U1
...

В Extract если условие истинно, мы добавляем тип к результату. Но при использовании Exclude мы «удалим» его, используя never.

Условные выражения и дженерики могут предложить гораздо больше, и статья о них не будет полной без упоминания о выводе.

Вывод с условными типами

Ключевое слово infer можно использовать только в выражении «условие» условного типа.

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

Мы используем стороннюю библиотеку для создания интерактивного графика.

Это макет сторонней функции:

Эта функция получает объект option, но библиотека не предоставляет options в виде отдельного типа (о нет!).

Вот пример использования этой функции:

К сожалению, мы не получаем никаких ошибок, даже несмотря на то, что мы неправильно написали width как wdth.

Если бы третья сторона раскрыла тип options, этого можно было бы избежать. К счастью, мы можем сами определить тип.

Для этого мы создадим универсальный тип с параметром типа F (F обозначает функцию):

Затем нам нужно будет проверить, что F является функцией с использованием условий:

Он проверяет, является ли F какой-либо функцией, но нам нужен более конкретный тип, функция хотя бы с одним аргументом.

А теперь, для магии TypeScript, нам нужно вывести тип первого аргумента. Для этого мы будем использовать ключевое слово infer.

Ключевое слово infer позволяет нам объявить новый общий тип в типе условия.

Здесь вместо того, чтобы присвоить arg конкретный тип, мы использовали ключевое слово infer для объявления нового универсального типа A, который представляет тип arg.

После объявления нового универсального типа остается только одно место для его повторного использования — истинная ветвь условного выражения.

В нашем случае, если проверка не удалась, то F не является функцией, которая получает хотя бы один аргумент. Так что тип A не может существовать.

Но если условие верно, то A существует, и мы можем его вернуть.

Но что делать, если условие ложно? Есть несколько вариантов:

  • Мы могли бы использовать F и возвращать исходный тип, если совпадения нет, что могло бы сбить с толку.
  • Мы могли бы использовать any, но это также вводит в заблуждение. Возможно, мы совпали, и у функции был первый аргумент any, но в обеих ветвях мы получаем одинаковый результат.
  • Если мы хотим точно знать, что мы не совпали и что-то пошло не так, мы можем использовать never.

never также исчезает при использовании в типах объединения, поэтому у нас может быть что-то вроде этого:

type Input = string | number | FirstFunctionArg<{}>

FirstFunctionArg<{}> это never и исчезает из типа Input.

Пришло время собрать все воедино. Вот наш универсальный тип:

И вот его использование:

Мы использовали FirstFunctionArg для получения типа первого аргумента options и, наконец, получили ошибку TypeScript для wdth , даже очень полезную, которая говорит нам:

Did you mean to write 'width'?

Сопоставленные типы

Сопоставленные типы — одна из моих любимых функций в TypeScript, так что вас ждет угощение.

Чтобы понять сопоставленные типы, нам сначала нужно понять следующее:

Индексированные типы доступа

Типы доступа к индексу — это типы, которые используются для поиска определенного свойства типа в других типах с использованием квадратных скобок [].

Здесь мы обращаемся к свойству ‘age’ и получаем номер типа.

Мы также можем получить доступ к типам, используя объединение:

Помните, что тип доступа может быть number, string или union.

Типы доступа к индексу очень эффективны, и я покажу вам несколько изящных способов их использования.

  1. Тип доступа к индексу — объект для объединения

Мы можем использовать индексные типы доступа для создания типа объединения из объекта const.

Как это работает?
1. В строке 5 мы используем as const, чтобы сделать все поля Prizes доступными только для чтения.
2. Затем мы получаем его тип, используя typeof .
3. Мы создайте union из типа, который мы только что создали, используя keyof .
4. Мы используем тип доступа index для создания объединения его значений.
5. Прибыль!

Типы доступа к индексу могут сделать еще больше!

2. Доступ к вложенным типам с помощью другого набора квадратных скобок

3. Доступ к массивам: типы

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

Подписи индекса

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

Например, используя строку в качестве ключей, мы получаем следующий код:

Здесь ключи (имена сотрудников) могут быть любой строкой, а значение должно быть числом.

Мы также можем использовать числовой тип в качестве ключей. Вот как выглядит код:

numberArray и stringNumberArray относятся к одному типу.

Потому что при индексации с числом JS фактически преобразует число в string.

Таким образом, индексация с номером ноль аналогична индексации со строкой '0'.

Сигнатуры индексов — полезный способ описания словаря, но у них есть одно большое ограничение: они заставляют все типы «значений» быть одинаковыми.

x имеет тип number, который нельзя присвоить строке.

Мы можем решить эту проблему, используя union в качестве значения подписи индекса.

Мы готовы изучить один из лучших инструментов в TypeScript.

С сигнатурами индекса мы определили произвольный ключ и значение его типа.

Что, если нам нужен объект типа только с определенными ключами?

Вот где сопоставленные типы очень удобны!

Чтобы понять основы сопоставленных типов, мы будем использовать следующий пример.

Мы создаем функцию, которая окрашивает определенную сторону коробки,
У нас есть тип Color: (да, мы поддерживаем только три).

type Color = 'red' | 'blue' |'green'

У нас есть тип Sides, который показан ниже:

type Sides = 'right' | 'top' | 'bottom' | 'left'

И мы хотим определить тип Box, который представляет собой карту между стороной и цветом.

Мы можем попытаться решить эту проблему с помощью индексных подписей:

С подписями индекса мы можем использовать только произвольные ключи, а это означает, что переменная box имеет допустимый тип Box (даже с опечаткой «лево»).

Мы хотим, чтобы наш ящик имел все Sides в качестве ключа и не имел других ключей.

Вот как это выглядит при использовании Mapped Types:

Наличие мысленной модели циклов помогает понять сопоставленные типы.

  1. Ключевое слово in в отображаемом типе указывает на «итерационный процесс».
  2. Справа от in находится объединение, которое мы «итерируем». В данном случае: Стороны.
  3. Слева от in находится элемент, с которым мы «итерируемся». В данном случае: Сторона.

И теперь мы получаем этот код, используя тип Box:

У нас должны быть все Sides в качестве ключей, и мы не можем добавить никакой другой ключ, кроме Sides. Если мы это сделаем, это вызовет ошибку типа.

Так как это статья о дженериках. Давайте посмотрим, как универсальные и сопоставленные типы работают вместе.

Запись с использованием сопоставленных типов

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

type Box = {[Side in Sides] : Color}

Мы хотим создать более универсальный и многоразовый тип, и мы будем использовать параметры универсального типа.

Мы назовем наш универсальный тип OwnRecord. Первым параметром типа будет KeyProps, который будет представлять union, над которым мы «итерируемся».

Мы наложим ограничение на наш общий параметр KeyProps extends string, что означает, что мы ожидаем, что он будет string.

Нам не хватает второго параметра, общего значения.

Универсальное значение не должно иметь каких-либо ограничений, поскольку мы хотим, чтобы пользователь этого типа определял любой тип Value, который он хочет.

Вот и все. У нас есть собственная общая запись. Давайте построим из него Box!

Box точно такой же тип, как и раньше, OwnRecord является универсальным и повторно используемым и похож на встроенный тип Record.

Единственная разница K extends keyof any.

type KeyOfAny = keyof any // string | number | symbol

Это означает, что встроенный тип записи также позволяет использовать number, symbol и string в качестве ключа.

Вы могли бы подумать, что мы остановимся здесь, да? Ни за что!

Мы хотим добавить типы доступа к индексу.

Сопоставленные типы с типами доступа к индексу

Пришло время объединить полученные знания для создания еще более мощных типов.

Мы хотим создать собственный addEventListner, который позволит нам прослушивать определенные типы событий.

Мы хотим разрешить прослушивание только click, mouseenter и mouseleave.

customEventListner необходимо иметь более ограниченные типы для eventType и listener.

Для этого мы будем использовать встроенный тип WindowEventMap и создадим его подмножество.

WindowEventMap — это сопоставление имени события и типа события.
Например, click: MouseEvent .

Для этого воспользуемся сопоставленными типами и индексными типами доступа.

Во-первых, мы перебираем объединение, используя сопоставленный тип. Затем мы используем ключ для получения значения из типа объекта, используя тип доступа к индексу.

У нас есть CustomEventMap, но мы хотим создать более удобный для повторного использования тип, который поможет нам делать это проще в будущем.

Во-первых, вытащите объединение, которое мы итерируем, в параметр типа.

Мы ввели параметр типа Keys для объединения и ограничили его значением keyof WindowEventMap.

Это означает, что в качестве параметра типа допустимо только объединение ключей WindowEventMap.

Теперь мы можем использовать любое подмножество ключей из WindowEventMap, что является довольно общим.

Можем ли мы сделать его еще более общим? Конечно можем!

Мы параметризуем WindowEventMap.

Мы вводим второй тип параметра с именем ObjType. Это тип, из которого мы создаем тип подобъекта.

Обратите внимание, что Keys ограничены keyof ObjType. Therefore, мы гарантируем, что в типе результата будут только ключи и значения из ObjType.

Теперь мы можем создать любой тип подобъекта, какой захотим! Мы только что создали фантастически полезный встроенный TypeScript Pick.

Который будет генерировать точно такой же тип.

И теперь мы готовы иметь безопасный тип customEventListner.

Мы разрешаем прослушивание подмножеств событий, и в качестве бонуса listner является типобезопасным, что является большим успехом!

Что, если вместо того, чтобы выбирать ключи из типа объекта, мы хотим опустить ключи из типа объекта?

Попытайтесь понять это сами, прежде чем продолжать прокручивать.

Мы можем использовать PartObj, который мы уже построили, чтобы решить эту проблему, первая его часть, вероятно, должна оставаться идентичной.

Нам все еще нужен параметр типа для объединения и типа объекта.

type OmitObj<ObjType,Keys extends keyof ObjType> =

Теперь нам нужно выяснить, по какому набору ключей нам нужно перебирать.
Мы не можем просто перебирать ключи; это даст нам тот же результат.

Мы должны выполнить итерацию по всем остальным ключам в типе объекта, кроме ключевого в Keys.

К счастью, мы уже знаем инструмент для этой работы (из предыдущих статей): Exclude.

Мы исключаем Keys из всех ключей ObjType (создаем объединение без Keys) и перебираем их, что приводит к подмножеству типа объекта с пропуском определенных ключей.

И ясно, что это еще один встроенный TypeScript Omit<T, K>.

Подвести итог

В этой статье мы рассмотрели многие функции TypeScript Generics. Мы начали с основ.

После этого мы научились задавать вопросы о типах, используя условные типы.

И мы дополнили его мощной функцией сопоставленных типов.

Я надеюсь, что вооружившись этими знаниями, вы сможете использовать дженерики более универсально и с пользой.

Источники

Я использовал документацию TypeScript:

Сопоставляемые типы

Условные типы

дженерики

И замечательный курс Frontend Masters:



Курс TypeScript | Станьте «Экспертом TypeScript в своей команде
TypeScript добавляет мощную систему типов поверх вашего JavaScript, чтобы выявлять ошибки до того, как они возникнут, и предоставлять…frontendmasters.com»



Продолжайте учиться!