Как компилятор угадывает T

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

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

Дженерик объявляют с угловыми скобками, а вызывают почти всегда без них: в first([10, 20]) скобок нет, а T откуда-то становится number. Компилятор читает его из аргументов, и читает по строгим правилам. Иногда получается ровно то, что вы задумали, иногда never, а иногда ошибка на втором аргументе там, где вы ждали объединение.

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

Объявляя function first<T>(items: T[]): T, вы не сообщаете, чем окажется T. Вы описываете связь: чем бы ни был тип элементов массива, ровно он вернётся наружу. Конкретику подставляет вызов — компилятор сопоставляет то, что написано в параметре, с типом аргумента, и вытаскивает T оттуда.

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

Диагностика приходит на седьмую строку: Тип "string" не может быть назначен для типа "number". Обратите внимание, о чём она — не о вызове, а о присваивании. Сам вызов безупречен: массив строк, T честно стал string, функция вернула строку. Спорным оказалось только то, куда эту строку положили.

Увидеть выведенный T целиком помогает тот же приём, что и в статье про вывод типов: присвоить результат туда, куда он заведомо не влезет, и прочитать тип в тексте отказа.

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

Зонд с null — не часть языка, а способ поговорить с компилятором там, где нет курсора. В редакторе тот же ответ даёт наведение мыши на имя функции в вызове.

T перенимает у аргумента и литеральный тип тоже — но виден он ровно там же, где виден без дженериков.

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

Шестая строка отвечает Тип ""left"", седьмая — Тип "string". Вызов в обоих случаях один и тот же; разница в том, куда легло значение. const переприсвоить нельзя — литерал сохраняется, let расширяет его до string. Правило знакомо по главе про базовые типы, дженерик тут ничего не меняет.

T не обязан быть типом параметра целиком. Компилятор разбирает форму параметра и форму аргумента параллельно, спускаясь внутрь, — и забирает T с той глубины, на которой он объявлен.

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

Самое полезное следствие — колбэки. Тип, выведенный из первого аргумента, компилятор переносит в параметр функции, которую вы передаёте вторым. Аннотировать её параметр не нужно — и TS7006, ошибка про неявный any, здесь не появится.

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

Параметров типа здесь два, и не назван ни один. T пришёл из массива, U — из выражения в колбэке; зонд подтверждает: Тип "boolean[]". Именно на этой цепочке держится удобство map, filter и всех остальных методов массива — их типы устроены точно так же.

Что написано в параметреЧем позвалиЧто вывелось в T
items: T[][10, 20]number
items: T[][1, "два"]string | number
items: T[][]never
box: { value: T }{ value: "раз" }string
fn: (item: T) => void(n: number) => …number
a: T, b: T1, "два"number и ошибка на втором
T не встречается в параметрах—тип цели присваивания, иначе unknown

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

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

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

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

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

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

Теперь Аргумент типа "number" нельзя назначить параметру типа "string". Функция не изменилась, изменился только порядок — верный признак того, что дело именно в выборе кандидата, а не в каком-то «правильном» типе.

А вот тот же набор значений, собранный в массив, объединение даёт — и без жалобы на сам вызов.

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

Разница не в дженерике, а в том, что происходит до него. Тип литерала [1, "два"] компилятор вычисляет сам, ещё ничего не зная про T, и получает (string | number)[]. Дальше с T[] сопоставляется уже готовый тип массива — кандидат ровно один, спорить не о чем. У двух отдельных параметров этого предварительного шага нет.

Если объединение нужно именно у pair, его придётся назвать вслух.

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

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

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

Пятая строка проходит молча, и это не ошибка компилятора: значений типа never не бывает, поэтому такое значение формально влезает куда угодно — проверять нечего. Возражение приходит только на седьмой: Свойство "toUpperCase" не существует в типе "never". Предупреждение можно проигнорировать — и тогда запуск покажет, что лежало в переменной на самом деле.

never в выведенном T — почти всегда сигнал «я не смог ничего вывести», а не осмысленный ответ. Увидели его в подсказке редактора — ищите пустой массив или пустой объект в аргументах.

Второй случай серьёзнее: T не встречается в параметрах вообще. Классика жанра — разбор JSON.

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

Пятая строка молчит: компилятор посмотрел на аннотацию переменной и взял T оттуда. Цель присваивания — тоже источник вывода, просто самый слабый. На шестой строке подсказки нет, T падает в unknown, и восьмая получает TS18046: "loose" относится к типу unknown. Из unknown ничего нельзя достать, пока его не сузили, — ровно то поведение, ради которого этот тип и придуман.

Если бы цель присваивания была равноправным источником, вывод в этом примере зашёл бы со стороны аннотации и всё сошлось бы.

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

Побеждает аргумент: T становится number, а на присваивании возникает Тип "number" не может быть назначен для типа "string". Цель присваивания подключается только тогда, когда из аргументов вывести нечего.

Поводов написать <…> прямо в вызове всего два, и оба уже встретились выше: выводить не из чего, как у parse<Point>, — или вывод сработал, но выдал не тот тип, что нужен, как у pair<string | number>.

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

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

Аргумент типа "string" нельзя назначить параметру типа "number". Без скобок этот вызов был бы совершенно законным: T стал бы string. Скобки не «подсказка компилятору», а обязательство.

Второе правило — всё или ничего. Если параметров типа несколько, назвать придётся все.

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

TS2558, Ожидались аргументы типа 2, получены: 1 — формулировка перевода корявая, но смысл прямой: аргументов типа ждали два. Дописать <string, number> можно, но чаще удобнее другое: дать параметру типа значение по умолчанию.

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

Без скобок T берётся из значения по умолчанию, со скобками — из них. Последняя строка получает Аргумент типа "number" нельзя назначить параметру типа "string": words — настоящий string[], а не какая-то поблажка. Значение по умолчанию не проверяет и не ограничивает T (это работа extends, о нём следующая статья) — оно просто подставляется, когда вывести нечего.

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

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

Ни одной диагностики — и TypeError на запуске. Мы пообещали, что в строке лежит Point, и никто эту строку не читал. Приём тот же по духу, что и утверждение типа через as: он не проверяет, а затыкает проверку. Всё, что приходит извне программы, честнее принимать как unknown и разбирать руками — этому посвящена отдельная глава части III.

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

  • T выводится из аргументов вызова: компилятор сопоставляет форму параметра с формой аргумента и забирает тип с той глубины, где T объявлен.
  • Когда T стоит в нескольких параметрах, объединение не собирается: побеждает первый кандидат, а остальные аргументы проверяются уже по нему.
  • Массив — исключение только на вид: тип его элементов вычисляется до вывода, поэтому [1, "два"] и даёт string | number.
  • Пустой массив даёт never, отсутствие T в параметрах — unknown; цель присваивания подключается лишь тогда, когда из аргументов вывести нечего.
  • Угловые скобки в вызове задают эталон для проверки аргументов и работают по принципу «всё или ничего»; смягчает это значение по умолчанию у параметра типа.
  • Явный T, который выводу неоткуда возразить (parse<Point>), — обещание без проверки: компилятор промолчит, а TypeError придёт на запуске.

1/4Функция объявлена как function first<T>(items: T[]): T. Её зовут так: first([1, "два"]). Что окажется в T?

2/4Функция объявлена как function pair<T>(a: T, b: T): T[]. Её зовут так: pair(1, "два"). Что произойдёт?

3/4Что окажется в T при вызове first([]) у той же функции first<T>(items: T[]): T?

4/4У mapAll<T, U>(items: T[], fn: (item: T) => U) вызвали mapAll<string>(["раз"], (s) => s.length). Что скажет компилятор?

Предскажите четыре диагностики

Четыре зонда, четыре вызова. До запуска выпишите на бумаге, что компилятор скажет о каждом: какой тип он назовёт в тексте отказа. Одна из четырёх строк промолчит — найдите её и объясните почему.

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

Три диагностики и одно молчание.

  • Строка 7: Тип "number" не может быть назначен для типа "null" — массив чисел, T стал number.
  • Строка 8: Тип "string | number" не может быть назначен для типа "null" — тип элементов посчитался до вывода и оказался объединением. У этой диагностики есть вложенная причина: Тип "string" не может быть назначен для типа "null" — компилятор показывает, какая именно часть объединения не подошла.
  • Строка 9 молчит: пустой массив — это never[], T вывелся в never, а значение типа never назначается в любой тип, включая null. Зонд здесь слеп — тот же случай, что разбирался в статье про вывод типов.
  • Строка 10: Тип "boolean[]" не может быть назначен для типа "null" — оба аргумента дали одного кандидата, а возвращается массив.

Убедиться в девятой строке можно, только сняв с переменной аннотацию: с : null объявленным типом становится сам null, и разговор пойдёт уже о нём.

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

Починить вызов, не трогая функцию

Функция чужая, менять её сигнатуру нельзя. Вызов должен пройти без диагностик, а печатать по-прежнему [1, "два"]. Правьте только строку вызова.

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

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

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

Теперь оба параметра имеют тип string | number, и оба аргумента в него влезают.

Соблазнительный обходной путь — подсунуть первым аргументом переменную объединённого типа — не работает:

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

Виновато сужение по потоку выполнения из главы про объединения: сразу после присваивания единицы компилятор знает, что в a лежит именно число, и кандидатом от первого аргумента становится number, а не объявленное объединение. Угловые скобки сомнений не оставляют — потому им здесь и место.

Убрать unknown из разбора JSON

Строка 5 получает TS18046: "user" относится к типу unknown. Сделайте так, чтобы user.name проходил проверку. Найдите два разных способа — и подумайте, чем они отличаются по надёжности.

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

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

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

По надёжности они не отличаются ничем — и в этом вся соль. Оба варианта одинаково ничего не проверяют: строку никто не читал, и если в JSON приедет {}, обещание рухнет уже на запуске. Разница чисто стилистическая: аннотация переменной заодно документирует, что в ней лежит, а скобки не дают вывалиться в unknown даже в промежуточном выражении.

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