Ловушки дженериков

дженерики≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Дженерик выглядит как знак качества: раз в сигнатуре есть <T>, значит функция типобезопасна. На деле параметр типа умеет и обманывать — молча пропускать что угодно, подменять собой as и ломать разбор файла из-за одной угловой скобки. Разберём пять ловушек, в которые попадают все.

Разделы темы:·

Параметр типа существует ради связи: он берёт тип в одном месте сигнатуры и переносит в другое. Если переносить некуда, объявлять его незачем — а выглядит такая функция ровно так же солидно.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики — и это не признак строгости, а признак того, что проверять было нечего. T вывелся в string | number | boolean и тут же был забыт: результат всё равно number. Замените сигнатуру на (items: unknown[]): number — поведение не изменится ни на йоту, зато читатель сразу увидит правду.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«Тип "string | undefined" не может быть назначен для типа "null"» — вот и ответ: T пришёл из массива и уехал в возвращаемое значение. Он встретился дважды, и в этом вся его работа.

Ограничение вторым появлением не считается. В shout<T extends string>(text: T): string параметр стоит в одном месте, а extends string лишь сужает то, что туда пустят; результат объявлен string и от T не зависит. Такая сигнатура полностью равна (text: string): string — только читается дольше.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор вывел T из первого аргумента и на втором сказал: «Аргумент типа "number" нельзя назначить параметру типа "string"». Выполнение это не остановило — массив всё равно напечатался, — но ошибка найдена ещё до запуска. Именно так выглядит работающий параметр типа.

СигнатураСколько раз встречается TВердикт
function count<T>(items: T[]): numberодинто же самое, что (items: unknown[])
function shout<T extends string>(text: T): stringодинто же самое, что (text: string)
function parseJson<T>(text: string): Tодин, и тот в возвратеопасно: разбор следующего раздела
function first<T>(items: T[]): T | undefinedдваработает: связал вход с выходом
function pair<T>(a: T, b: T): T[]триработает: требует одинаковых аргументов

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

Худший вид одинокого параметра — тот, который стоит только в типе результата. Тип тогда называет вызывающая сторона, а компилятору остаётся поверить на слово: проверить-то нечего, значение приходит из JSON.parse, то есть из any.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор не сказал ни слова, а запуск дал TypeError: settings.port.toFixed is not a function. В JSON лежала строка "8080", но settings объявлен как { port: number } — потому что мы так попросили. Это ровно то же самое, что написать as, только слова as в коде нет и подозрений оно не вызывает.

Честный вариант — вернуть unknown и заставить вызывающего проверить. Тогда компилятор снова на вашей стороне: пока тип не сужен, из значения ничего не достать.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Разница не в строгости ради строгости: первый вариант обещал number и уронил программу в проде, второй заставил обработать «не число» в тот же день, когда код писался.

Нужен — пусть T появится дважды. Возьмите вторым параметром функцию, которая превращает unknown в нужный тип: теперь T выводится из настоящего кода, а не из пожелания вызывающего.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

T встретился в типе build и в возврате — правило двух появлений соблюдено, и за результат отвечает не обещание, а выполненный код.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Опять тишина при компиляции и TypeError при запуске. Обёртка выглядит типизированной, но T взялся из аргумента, аргумент был any — и box.value тоже стал any. Одно нетипизированное значение обесценило всю цепочку вниз.

Замените в той же программе одно слово — any на unknown — и проверки вернутся.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
«"box.value" относится к типу unknown» — ошибка найдена там, где она и была. Запуск, как обычно, всё равно состоялся и упал: диагностика не отменяет выполнения, а предупреждает о нём заранее.

Вторая половина той же ловушки живёт внутри реализации. Компилятор возражает против операции над T — и рука тянется дописать as any.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Без as any компилятор говорил дело: TS2365, «Оператор "+=" невозможно применить к типам "number" и "T"». Про T ведь ничего не известно — это может быть строка, объект, что угодно. Утверждение не сделало операцию верной, а лишь запретило о ней говорить: функция вернула строку "01020" с типом number, и следующая же строка получила TypeError.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика внутри дженерика почти всегда справедлива: она говорит, что о T известно слишком мало. Лечится ограничением или дополнительным параметром, а не утверждением типа.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«"T" относится только к типу, но используется здесь как значение» — и следом, уже на запуске, ReferenceError: T is not defined. Две ошибки об одном: new T() уехало в JavaScript как есть, а никакого T там нет и быть не может. По той же причине не работают typeof T и value instanceof T.

Если функции нужно что-то создавать, передайте ей значение, а не тип: фабрику или сам класс. Тип при этом выведется сам — и T, как положено, встретится дважды.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Последняя ловушка не про типы, а про разбор текста. В обычном .ts стрелочная функция с параметром типа пишется без затей: const identity = <T>(value: T) => value;. Перенесите ту же строку в файл .tsx — и компилятор перестанет понимать написанное.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Четыре диагностики, и ни одна не про дженерики: «Не удается найти имя "T"», «Элемент JSX "T" не содержит соответствующий закрывающий тег», «Непредвиденный токен. Возможно, вы хотели использовать "{'&gt;'}" или "&amp;gt;"?» и «Ожидалось "</"». Третья читается страннее всех: разборщик уже не сомневается, что перед ним разметка, и предлагает экранировать скобку — так в JSX и пишут символ «больше». В файле с расширением .tsx угловая скобка — это начало разметки, и грамматика решает спор в её пользу: компилятор увидел тег <T> и до конца файла искал закрывающий. Запуск это подтвердил по-своему: ReferenceError: T is not defined — «тег» честно превратился в вызов, которым обычно строят разметку, а первым аргументом этому вызову досталось имя T, которого во время выполнения не существует.

Нужен признак, по которому разборщик поймёт: это список параметров типа. Достаточно висячей запятой.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Запятая ничего не значит для системы типов — это чистая подсказка разборщику, вроде скобки в спорном выражении. Способов дать её три, и все рабочие.

ЗаписьЧем хороша
const identity = <T,>(value: T) => value;короче всех; именно так расставляет запятую Prettier
const identity = <T extends unknown>(value: T) => value;без «странной» запятой, зато длиннее и ограничение здесь фиктивное
function identity<T>(value: T) { return value; }обычное объявление функции конфликта с разметкой не создаёт вовсе

Ошибка всплывает при переезде кода из .ts в .tsx: файл переименовали, а стрелка осталась прежней. Диагностика при этом говорит про JSX, и связь с дженериком приходится вспоминать самому.

  • Параметр типа обязан встретиться в сигнатуре хотя бы дважды: одно появление ничего ни с чем не связывает.
  • T только в возвращаемом типе — это скрытое as: тип называет вызывающий, а проверить его нечем.
  • На границе программы вместо any берите unknown — иначе T выведется в any и вся цепочка перестанет проверяться.
  • as any внутри дженерика не чинит операцию, а запрещает о ней говорить; недостающее знание добавляют ограничением или лишним параметром.
  • Типы стираются: new T(), typeof T и instanceof T невозможны — передавайте фабрику или класс значением.
  • В файлах .tsx запись <T>(value: T) => value читается как JSX; спасает висячая запятая <T,>.

1/4В сигнатуре function count<T>(items: T[]): number параметр T встречается ровно один раз. Что это означает?

2/4Функция parseJson<T>(text: string): T возвращает JSON.parse(text). Вызываем её как parseJson<{ port: number }> для JSON, где port — строка. Что будет?

3/4const raw: any = "8080"; передаём в wrap<T>(value: T): { value: T }. Какой тип получит box.value?

4/4Строка const identity = <T>(value: T) => value; лежит в файле .tsx и не компилируется. Почему?

Честная сигнатура

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

У pluck параметр встречается дважды: он берёт тип элемента из массива и отдаёт его в результат — без него функция вернула бы unknown. У describe T встречается один раз: значение сразу уходит в String(), а наружу выдаётся string. Значит, дженерик там лишний.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вызовы не изменились, вывод тоже, а сигнатура перестала обещать связь, которой нет. unknown здесь лучше any: String() принимает что угодно, но случайную операцию над значением компилятор уже не пропустит.

Убрать as any

Функция обещает соединить элементы, повысив регистр, — и делает это утверждением as any. Первый вызов работает, второй падает на запуске. Уберите as any так, чтобы второй вызов ловил компилятор, а не браузер.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сначала уберите утверждение — компилятор объяснит суть претензии: «Свойство "toUpperCase" не существует в типе "T"». Он прав, о T ничего не известно. Значит, знание нужно добавить в сигнатуру.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Второй вызов теперь ловится при компиляции. Но посчитайте появления T: он встречается один раз, в параметре, — и по правилу из первого раздела его пора выкинуть.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Если функция и правда должна работать с любыми элементами, честный вариант — попросить у вызывающего способ превратить элемент в строку: joinLoud<T>(items: T[], sep: string, toText: (item: T) => string). Тогда T появляется дважды и снова начинает работать.

Стрелка в .tsx

Этот файл называется list.tsx, и компилятор выдаёт на него ошибки про закрывающий тег. Почините строку, не превращая стрелку в обычное объявление function.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Разборщику нужен признак, что за скобкой идёт список параметров типа, а не разметка. Короткий способ — висячая запятая.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Тот же эффект даёт ограничение: const pickFirst = <T extends unknown>(items: T[]) => items[0];. Оно ничего не ограничивает и нужно ровно для того же — снять неоднозначность разбора. В файле .ts обе записи тоже допустимы, поэтому переносить код между расширениями безопаснее сразу с запятой.