Дженерик: тип как параметр
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Функция, которая отдаёт то же, что получила, — обычное дело: первый элемент массива, значение из кеша, аргумент после проверки. А записать это обещание в типах нечем: any связь между входом и выходом стирает, объединение размывает. Дженерик добавляет функции второй вид параметров — параметр типа: он подставляется в момент вызова и держит связь.
Задача простая: вернуть первый элемент массива. Какие в нём элементы, заранее неизвестно — функция должна работать с любыми. Первое, что приходит в голову после второй главы, — поставить any и на входе, и на выходе.
Ни одной диагностики. Первый вызов и правда дал строку, второй — число, а у чисел нет toUpperCase: в консоли настоящий TypeError. Компилятор мог бы это поймать — он же видел, что в массиве числа, — но мы сами закрыли ему глаза.
any не описывает связь входа и выхода, а отменяет её. Для компилятора firstItem — функция, которая принимает что угодно и возвращает что угодно, и эти два «что угодно» друг с другом никак не связаны. Дальше any растекается по коду.
У box тип any, а значит, у него «есть» любое свойство — в том числе titel. Опечатка спокойно доживает до выполнения и падает там: Cannot read properties of undefined.
Попытка вторая — перечислить типы объединением. Диагностика появляется, но радости от неё мало.
«Свойство "toUpperCase" не существует в типе "string | number"», и ниже причина — про number. Формально компилятор прав, а по делу нет: в этот вызов мы передали массив строк и знаем это точно. Приходится сужать по typeof там, где сужать нечего. И список типов конечен: понадобился boolean — правьте сигнатуру.
Нужно сказать иначе: «на выходе тот же тип, что на входе» — не называя, какой именно.
У функции уже есть параметры — в круглых скобках, для значений. Дженерик добавляет вторые, в угловых, — для типов. T здесь не какой-то особенный тип, а имя-заполнитель: ровно как list — имя-заполнитель для массива, который придёт при вызове.
Одна функция, два вызова, две разные проверки. В первом на место T встал string — toUpperCase разрешён. Во втором T оказался number: «Свойство "toUpperCase" не существует в типе "number"», и указана седьмая строка — место ошибки, а не функция, которая ни в чём не виновата. Дальше по традиции: диагностика выполнение не отменяет, РИКО успевает напечататься до падения.
Сравните с самым первым примером: код тот же, изменились две аннотации — any[] стал T[], any стал T. Одна буква, повторённая дважды, и есть связь «что вошло, то и вышло».
| Параметр значения | Параметр типа | |
|---|---|---|
| Где объявлен | в круглых скобках: function f(list) | в угловых: function f<T>() |
| Что подставляют | значение: f([1, 2]) | тип: f<number>([1, 2]) |
| Когда подставляют | при вызове, во время работы программы | при вызове, во время проверки типов |
| Что остаётся в JavaScript | аргумент | ничего, угловые скобки стираются |
Последняя строка таблицы — та же мысль, что и в первой главе: типы до браузера не доезжают. В готовом JavaScript от <T> не остаётся ни следа, это обычная функция с одним параметром.
Подставить тип можно и руками — теми же угловыми скобками, только уже при вызове.
Здесь мы объявили T строкой, а принесли числа — и компилятор сверил аргумент с обещанием: «Тип "number" не может быть назначен для типа "string"». Диагностик две, по одной на каждый элемент массива.
В живом коде угловые скобки при вызове почти не пишут: компилятор выводит T из аргументов сам. Как именно — следующая статья главы.
number[] из второй главы — сокращённая запись. Полная выглядит так, что теперь её понятно читать.
Array<number> и number[] — один и тот же тип, две записи. У Map параметров типа два, Map<K, V> — ключ и значение; мы подставили string и number, и четвёртая строка попалась: «Аргумент типа "string" нельзя назначить параметру типа "number"». Никакой отдельной поддержки Map внутри компилятора нет — работает тот самый механизм, который мы только что написали руками.
Промисы из части I — тоже дженерик: Promise<T>, где T — тип того, чем промис разрешится.
await снимает обёртку и отдаёт string — поэтому toUpperCase на месте. А седьмая строка показывает, что параметр типа участвует в сравнении наравне со всем прочим: «Тип "Promise<string>" не может быть назначен для типа "Promise<number>"», и вложенной причиной — про string и number.
Почти всё, что хранит чужие значения: Set<T>, Map<K, V>, WeakMap<K, V>, Promise<T>, Array<T> и его неизменяемая версия ReadonlyArray<T>. Знакомый по главе про индексные сигнатуры Record<K, V> — оттуда же.
«Аргумент типа "number" нельзя назначить параметру типа "string"»: Set запомнил, что в нём строки, и стережёт это на каждом add.
«Вернуть то же самое» — частный случай. Параметр типа можно поставить в любое место сигнатуры, и связь опишется любая.
Вход — одно значение типа T, выход — массив T[]. У repeat(true, 3) результат boolean[], у repeat("ок", 2) — string[], и join у него есть. А последняя строка ловится: toUpperCase у boolean нет. Обратите внимание, что times остался обычным number — не всё в сигнатуре обязано быть параметром типа.
Параметров типа тоже может быть несколько — перечисляются через запятую, как обычные.
A схватил string, B — number, а результат описан кортежем [A, B] — тем самым кортежем из второй главы, где важен порядок. Поэтому у row[0] есть toUpperCase, у row[1] — toFixed, и перепутать их компилятор не даст.
Имена A и B выбраны для читаемости: две буквы подряд лучше видно, чем T и U. Компилятору всё равно, как их зовут.
Параметр типа не ограничен примитивами: T — это любой тип, включая тот, который вы описали сами.
Последняя строка — «Свойство "email" не существует в типе "User"». T стал User целиком, со всеми полями и со всей строгостью, которая к User прилагается.
Соблазн читать T как «вежливый any» велик. Проверим его на функции, которая просто выкрикивает переданное.
TS2339, «Свойство "toUpperCase" не существует в типе "T"» — тот же код, что и на опечатке в имени поля, только в типе теперь стоит буква. И это при том, что вызвали мы shout со строкой и РИКО в консоли напечаталось. Тело функции проверяется один раз, до всех вызовов, и обязано быть верным для любой подстановки. Раз T может оказаться числом, toUpperCase под запретом.
Вот здесь и проходит граница между any и T. any разрешает внутри всё и не обещает наружу ничего. T наоборот: внутри почти ничего нельзя, зато наружу дано твёрдое обещание. Сказать компилятору «T — не совсем что угодно, а хотя бы что-то со строчными методами» можно, для этого есть слово extends, и ему отведена третья статья главы.
Из того же правила следует второе ограничение: значение типа T нельзя сочинить — его можно только получить снаружи.
Диагностика с вложенной причиной, читается как разбор из первой главы: «Тип "number" не может быть назначен для типа "T"», а ниже почему — «Возможно создание экземпляра "T" с произвольным типом, который может быть не связан с "number"». По-человечески: вы обещали вернуть тот тип, который выберет вызывающий, а вернули ноль. Пятая и шестая строки это и доказывают: T выбрали string, из функции пришёл 0, и toUpperCase падает уже при выполнении.
Наконец, T — обычное имя, и живёт оно только внутри своей функции.
«Не удается найти имя "T"»: снаружи параметра типа не существует — ровно как не существует снаружи имени параметра функции. Буква T ничем не выделена, её просто принято брать первой.
| Имя | Откуда | Где встречается |
|---|---|---|
T | Type | единственный параметр типа |
U, V | следующие буквы алфавита | второй и третий параметры |
K, V | Key, Value | пары ключ-значение, как в Map<K, V> |
E | Element | элемент коллекции |
Item, Value | обычное слово | когда буква перестаёт читаться |
Однобуквенные имена — традиция, а не правило. Когда параметров типа три и они разной природы, слово читается лучше буквы.
anyв сигнатуре не описывает связь входа и выхода, а отменяет её вместе со всеми проверками.- Дженерик — это параметр типа: имя в угловых скобках, на место которого при вызове встаёт настоящий тип.
- Одна буква
T, повторённая в параметре и в возврате, и есть обещание «что вошло, то и вышло». - Параметров типа бывает несколько, и стоять они могут где угодно в сигнатуре:
Tна входе,T[]или[A, B]на выходе. Array<T>,Map<K, V>,Promise<T>— тот же механизм: дженериками вы пользовались задолго до этой статьи.- Внутри функции про
Tне известно почти ничего: тело проверяется один раз и обязано подойти любой будущей подстановке.
1/4Функция объявлена как function firstItem(list: any[]): any, а в коде написано firstItem([31, 42]).toUpperCase(). Что скажет компилятор?
2/4Есть function pair<A, B>(first: A, second: B): [A, B] и вызов pair("Рико", 31). Какой тип у второго элемента результата?
3/4Внутри function shout<T>(value: T) написали value.toUpperCase(), а вызвали shout("Рико"). Что будет?
4/4Чем Array<number> отличается от number[]?
Уберите any
Функция возвращает последний элемент массива, и обе аннотации у неё any. Из-за этого ошибка в седьмой строке видна только в браузере. Перепишите сигнатуру на параметр типа так, чтобы компилятор поймал её до запуска — тело менять не нужно.
Объявляем параметр типа T в угловых скобках и ставим его на оба места, где стоял any: массив на входе — T[], результат — T.
Первый вызов работает как раньше — T стал string. Во втором T — это number, и седьмая строка теперь подсвечена в редакторе, а не падает у пользователя.
Пара наоборот
swap меняет местами элементы пары. С any компилятор согласен на любую глупость: в четвёртой строке пришли строка и число, а после перестановки к числу применяют toUpperCase — и никакой диагностики. Опишите функцию двумя параметрами типа так, чтобы результат был кортежем в обратном порядке.
Два параметра типа, A и B, и кортеж на выходе с переставленными местами: [B, A].
Теперь swapped[0] — число, а swapped[1] — строка, и swapped[0].toUpperCase() перестаёт компилироваться. Тип возврата пришлось написать руками: сам факт перестановки внутри тела — не то, о чём компилятор догадается.
Значение по умолчанию
Функция должна вернуть первый элемент, а для пустого массива — что-нибудь запасное. Компилятор возражает на третью строку. Прочитайте диагностику вместе с вложенной причиной, объясните её себе и почините функцию, не выбрасывая T.
Причина в диагностике сказана прямо: «Возможно создание экземпляра "T" с произвольным типом, который может быть не связан с "number"». Вызывающий выбирает T сам, и ноль подойдёт только если он выбрал числа. Сочинить значение типа T внутри функции невозможно — его нужно получить снаружи, вторым параметром.
Одна буква T теперь связывает три места: элементы массива, запасное значение и результат. Передать строку с запасным нулём компилятор не даст.