Ловушки дженериков
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Дженерик выглядит как знак качества: раз в сигнатуре есть <T>, значит функция типобезопасна. На деле параметр типа умеет и обманывать — молча пропускать что угодно, подменять собой as и ломать разбор файла из-за одной угловой скобки. Разберём пять ловушек, в которые попадают все.
Параметр типа существует ради связи: он берёт тип в одном месте сигнатуры и переносит в другое. Если переносить некуда, объявлять его незачем — а выглядит такая функция ровно так же солидно.
Ни одной диагностики — и это не признак строгости, а признак того, что проверять было нечего. T вывелся в string | number | boolean и тут же был забыт: результат всё равно number. Замените сигнатуру на (items: unknown[]): number — поведение не изменится ни на йоту, зато читатель сразу увидит правду.
А теперь параметр, которому есть что связывать. Чтобы увидеть выведенный тип без наведения курсора, годится приём из статьи про сужение: присвоить результат переменной заведомо неподходящего типа и прочитать диагностику.
«Тип "string | undefined" не может быть назначен для типа "null"» — вот и ответ: T пришёл из массива и уехал в возвращаемое значение. Он встретился дважды, и в этом вся его работа.
Ограничение вторым появлением не считается. В shout<T extends string>(text: T): string параметр стоит в одном месте, а extends string лишь сужает то, что туда пустят; результат объявлен string и от T не зависит. Такая сигнатура полностью равна (text: string): string — только читается дольше.
Считать появления полезно и в аргументах. Один и тот же T у двух параметров — это требование: типы должны совпасть.
Компилятор вывел 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.
Компилятор не сказал ни слова, а запуск дал TypeError: settings.port.toFixed is not a function. В JSON лежала строка "8080", но settings объявлен как { port: number } — потому что мы так попросили. Это ровно то же самое, что написать as, только слова as в коде нет и подозрений оно не вызывает.
Честный вариант — вернуть unknown и заставить вызывающего проверить. Тогда компилятор снова на вашей стороне: пока тип не сужен, из значения ничего не достать.
Разница не в строгости ради строгости: первый вариант обещал number и уронил программу в проде, второй заставил обработать «не число» в тот же день, когда код писался.
Нужен — пусть T появится дважды. Возьмите вторым параметром функцию, которая превращает unknown в нужный тип: теперь T выводится из настоящего кода, а не из пожелания вызывающего.
T встретился в типе build и в возврате — правило двух появлений соблюдено, и за результат отвечает не обещание, а выполненный код.
Дженерик не добавляет строгости сам по себе: он ровно настолько строг, насколько строги типы, которые в него въехали. А any въезжает молча.
Опять тишина при компиляции и TypeError при запуске. Обёртка выглядит типизированной, но T взялся из аргумента, аргумент был any — и box.value тоже стал any. Одно нетипизированное значение обесценило всю цепочку вниз.
Замените в той же программе одно слово — any на unknown — и проверки вернутся.
Вторая половина той же ловушки живёт внутри реализации. Компилятор возражает против операции над T — и рука тянется дописать as any.
Без as any компилятор говорил дело: TS2365, «Оператор "+=" невозможно применить к типам "number" и "T"». Про T ведь ничего не известно — это может быть строка, объект, что угодно. Утверждение не сделало операцию верной, а лишь запретило о ней говорить: функция вернула строку "01020" с типом number, и следующая же строка получила TypeError.
Правильный ответ — не глушить компилятор, а дать функции недостающее знание. Например, вторым параметром — способ получить из T число.
Диагностика внутри дженерика почти всегда справедлива: она говорит, что о T известно слишком мало. Лечится ограничением или дополнительным параметром, а не утверждением типа.
Типы существуют только во время компиляции: в выданном JavaScript от них не остаётся ничего. Дженерик — не исключение, и это ломает самую соблазнительную идею новичка.
«"T" относится только к типу, но используется здесь как значение» — и следом, уже на запуске, ReferenceError: T is not defined. Две ошибки об одном: new T() уехало в JavaScript как есть, а никакого T там нет и быть не может. По той же причине не работают typeof T и value instanceof T.
Если функции нужно что-то создавать, передайте ей значение, а не тип: фабрику или сам класс. Тип при этом выведется сам — и T, как положено, встретится дважды.
Отсюда общее правило чтения чужих библиотек: если функция обещает вернуть T, а во время выполнения о T ничего не знает — значит, за обещание отвечаете вы, а не компилятор.
Последняя ловушка не про типы, а про разбор текста. В обычном .ts стрелочная функция с параметром типа пишется без затей: const identity = <T>(value: T) => value;. Перенесите ту же строку в файл .tsx — и компилятор перестанет понимать написанное.
Четыре диагностики, и ни одна не про дженерики: «Не удается найти имя "T"», «Элемент JSX "T" не содержит соответствующий закрывающий тег», «Непредвиденный токен. Возможно, вы хотели использовать "{'>'}" или "&gt;"?» и «Ожидалось "</"». Третья читается страннее всех: разборщик уже не сомневается, что перед ним разметка, и предлагает экранировать скобку — так в JSX и пишут символ «больше». В файле с расширением .tsx угловая скобка — это начало разметки, и грамматика решает спор в её пользу: компилятор увидел тег <T> и до конца файла искал закрывающий. Запуск это подтвердил по-своему: ReferenceError: T is not defined — «тег» честно превратился в вызов, которым обычно строят разметку, а первым аргументом этому вызову досталось имя T, которого во время выполнения не существует.
Нужен признак, по которому разборщик поймёт: это список параметров типа. Достаточно висячей запятой.
Запятая ничего не значит для системы типов — это чистая подсказка разборщику, вроде скобки в спорном выражении. Способов дать её три, и все рабочие.
| Запись | Чем хороша |
|---|---|
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 и не компилируется. Почему?
Честная сигнатура
Одна из этих двух функций объявлена дженериком зря. Найдите её по правилу двух появлений и перепишите сигнатуру так, чтобы она говорила правду. Поведение меняться не должно.
У pluck параметр встречается дважды: он берёт тип элемента из массива и отдаёт его в результат — без него функция вернула бы unknown. У describe T встречается один раз: значение сразу уходит в String(), а наружу выдаётся string. Значит, дженерик там лишний.
Вызовы не изменились, вывод тоже, а сигнатура перестала обещать связь, которой нет. unknown здесь лучше any: String() принимает что угодно, но случайную операцию над значением компилятор уже не пропустит.
Убрать as any
Функция обещает соединить элементы, повысив регистр, — и делает это утверждением as any. Первый вызов работает, второй падает на запуске. Уберите as any так, чтобы второй вызов ловил компилятор, а не браузер.
Сначала уберите утверждение — компилятор объяснит суть претензии: «Свойство "toUpperCase" не существует в типе "T"». Он прав, о T ничего не известно. Значит, знание нужно добавить в сигнатуру.
Второй вызов теперь ловится при компиляции. Но посчитайте появления T: он встречается один раз, в параметре, — и по правилу из первого раздела его пора выкинуть.
Если функция и правда должна работать с любыми элементами, честный вариант — попросить у вызывающего способ превратить элемент в строку: joinLoud<T>(items: T[], sep: string, toText: (item: T) => string). Тогда T появляется дважды и снова начинает работать.
Стрелка в .tsx
Этот файл называется list.tsx, и компилятор выдаёт на него ошибки про закрывающий тег. Почините строку, не превращая стрелку в обычное объявление function.
Разборщику нужен признак, что за скобкой идёт список параметров типа, а не разметка. Короткий способ — висячая запятая.
Тот же эффект даёт ограничение: const pickFirst = <T extends unknown>(items: T[]) => items[0];. Оно ничего не ограничивает и нужно ровно для того же — снять неоднозначность разбора. В файле .ts обе записи тоже допустимы, поэтому переносить код между расширениями безопаснее сразу с запятой.