Как компилятор угадывает T
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Дженерик объявляют с угловыми скобками, а вызывают почти всегда без них: в first([10, 20]) скобок нет, а T откуда-то становится number. Компилятор читает его из аргументов, и читает по строгим правилам. Иногда получается ровно то, что вы задумали, иногда never, а иногда ошибка на втором аргументе там, где вы ждали объединение.
Объявляя function first<T>(items: T[]): T, вы не сообщаете, чем окажется T. Вы описываете связь: чем бы ни был тип элементов массива, ровно он вернётся наружу. Конкретику подставляет вызов — компилятор сопоставляет то, что написано в параметре, с типом аргумента, и вытаскивает T оттуда.
Диагностика приходит на седьмую строку: Тип "string" не может быть назначен для типа "number". Обратите внимание, о чём она — не о вызове, а о присваивании. Сам вызов безупречен: массив строк, T честно стал string, функция вернула строку. Спорным оказалось только то, куда эту строку положили.
Увидеть выведенный T целиком помогает тот же приём, что и в статье про вывод типов: присвоить результат туда, куда он заведомо не влезет, и прочитать тип в тексте отказа.
Зонд с null — не часть языка, а способ поговорить с компилятором там, где нет курсора. В редакторе тот же ответ даёт наведение мыши на имя функции в вызове.
T перенимает у аргумента и литеральный тип тоже — но виден он ровно там же, где виден без дженериков.
Шестая строка отвечает Тип ""left"", седьмая — Тип "string". Вызов в обоих случаях один и тот же; разница в том, куда легло значение. const переприсвоить нельзя — литерал сохраняется, let расширяет его до string. Правило знакомо по главе про базовые типы, дженерик тут ничего не меняет.
T не обязан быть типом параметра целиком. Компилятор разбирает форму параметра и форму аргумента параллельно, спускаясь внутрь, — и забирает T с той глубины, на которой он объявлен.
Самое полезное следствие — колбэки. Тип, выведенный из первого аргумента, компилятор переносит в параметр функции, которую вы передаёте вторым. Аннотировать её параметр не нужно — и TS7006, ошибка про неявный any, здесь не появится.
Параметров типа здесь два, и не назван ни один. 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: T | 1, "два" | number и ошибка на втором |
T не встречается в параметрах | — | тип цели присваивания, иначе unknown |
Строка про пустой массив, строка про два параметра и последняя строка выглядят неожиданно — ими и займёмся дальше.
Интуиция подсказывает: если T стоит в двух параметрах, а аргументы разного типа, компилятор сложит их в объединение. Проверьте догадку до запуска.
Вместо объединения — Аргумент типа "string" нельзя назначить параметру типа "number". Компилятор собирает кандидатов по всем местам, где встретился T, и выбирает одного: такого, в кого влезают остальные. string в number не влезает, number в string тоже — общего победителя нет, и тогда берётся первый кандидат по порядку, а остальные аргументы проверяются уже по нему.
Если победителя выбирают по порядку, то порядок аргументов должен менять текст ошибки. Так и есть.
Теперь Аргумент типа "number" нельзя назначить параметру типа "string". Функция не изменилась, изменился только порядок — верный признак того, что дело именно в выборе кандидата, а не в каком-то «правильном» типе.
А вот тот же набор значений, собранный в массив, объединение даёт — и без жалобы на сам вызов.
Разница не в дженерике, а в том, что происходит до него. Тип литерала [1, "два"] компилятор вычисляет сам, ещё ничего не зная про T, и получает (string | number)[]. Дальше с T[] сопоставляется уже готовый тип массива — кандидат ровно один, спорить не о чем. У двух отдельных параметров этого предварительного шага нет.
Если объединение нужно именно у pair, его придётся назвать вслух.
Кандидатов может не найтись вовсе. Первый случай — пустой массив: элементов в нём нет, брать тип неоткуда, и типом элемента становится never — тип, у которого не бывает значений.
Пятая строка проходит молча, и это не ошибка компилятора: значений типа never не бывает, поэтому такое значение формально влезает куда угодно — проверять нечего. Возражение приходит только на седьмой: Свойство "toUpperCase" не существует в типе "never". Предупреждение можно проигнорировать — и тогда запуск покажет, что лежало в переменной на самом деле.
never в выведенном T — почти всегда сигнал «я не смог ничего вывести», а не осмысленный ответ. Увидели его в подсказке редактора — ищите пустой массив или пустой объект в аргументах.
Второй случай серьёзнее: T не встречается в параметрах вообще. Классика жанра — разбор JSON.
Пятая строка молчит: компилятор посмотрел на аннотацию переменной и взял T оттуда. Цель присваивания — тоже источник вывода, просто самый слабый. На шестой строке подсказки нет, T падает в unknown, и восьмая получает TS18046: "loose" относится к типу unknown. Из unknown ничего нельзя достать, пока его не сузили, — ровно то поведение, ради которого этот тип и придуман.
Если бы цель присваивания была равноправным источником, вывод в этом примере зашёл бы со стороны аннотации и всё сошлось бы.
Побеждает аргумент: T становится number, а на присваивании возникает Тип "number" не может быть назначен для типа "string". Цель присваивания подключается только тогда, когда из аргументов вывести нечего.
Поводов написать <…> прямо в вызове всего два, и оба уже встретились выше: выводить не из чего, как у parse<Point>, — или вывод сработал, но выдал не тот тип, что нужен, как у pair<string | number>.
Первое, что стоит понять про угловые скобки: они не отключают проверку. Наоборот, они задают эталон, по которому аргументы теперь и проверяют.
Аргумент типа "string" нельзя назначить параметру типа "number". Без скобок этот вызов был бы совершенно законным: T стал бы string. Скобки не «подсказка компилятору», а обязательство.
Второе правило — всё или ничего. Если параметров типа несколько, назвать придётся все.
TS2558, Ожидались аргументы типа 2, получены: 1 — формулировка перевода корявая, но смысл прямой: аргументов типа ждали два. Дописать <string, number> можно, но чаще удобнее другое: дать параметру типа значение по умолчанию.
Без скобок T берётся из значения по умолчанию, со скобками — из них. Последняя строка получает Аргумент типа "number" нельзя назначить параметру типа "string": words — настоящий string[], а не какая-то поблажка. Значение по умолчанию не проверяет и не ограничивает T (это работа extends, о нём следующая статья) — оно просто подставляется, когда вывести нечего.
И теперь опасность, ради которой стоило всё это читать. Явно названный T — обещание, и проверить его компилятору нечем.
Ни одной диагностики — и 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). Что скажет компилятор?
Предскажите четыре диагностики
Четыре зонда, четыре вызова. До запуска выпишите на бумаге, что компилятор скажет о каждом: какой тип он назовёт в тексте отказа. Одна из четырёх строк промолчит — найдите её и объясните почему.
Три диагностики и одно молчание.
- Строка 7:
Тип "number" не может быть назначен для типа "null"— массив чисел,Tсталnumber. - Строка 8:
Тип "string | number" не может быть назначен для типа "null"— тип элементов посчитался до вывода и оказался объединением. У этой диагностики есть вложенная причина:Тип "string" не может быть назначен для типа "null"— компилятор показывает, какая именно часть объединения не подошла. - Строка 9 молчит: пустой массив — это
never[],Tвывелся вnever, а значение типаneverназначается в любой тип, включаяnull. Зонд здесь слеп — тот же случай, что разбирался в статье про вывод типов. - Строка 10:
Тип "boolean[]" не может быть назначен для типа "null"— оба аргумента дали одного кандидата, а возвращается массив.
Убедиться в девятой строке можно, только сняв с переменной аннотацию: с : null объявленным типом становится сам null, и разговор пойдёт уже о нём.
Починить вызов, не трогая функцию
Функция чужая, менять её сигнатуру нельзя. Вызов должен пройти без диагностик, а печатать по-прежнему [1, "два"]. Правьте только строку вызова.
Раз объединение компилятор здесь не соберёт, его нужно назвать самому — угловыми скобками прямо в вызове.
Теперь оба параметра имеют тип string | number, и оба аргумента в него влезают.
Соблазнительный обходной путь — подсунуть первым аргументом переменную объединённого типа — не работает:
Виновато сужение по потоку выполнения из главы про объединения: сразу после присваивания единицы компилятор знает, что в a лежит именно число, и кандидатом от первого аргумента становится number, а не объявленное объединение. Угловые скобки сомнений не оставляют — потому им здесь и место.
Убрать unknown из разбора JSON
Строка 5 получает TS18046: "user" относится к типу unknown. Сделайте так, чтобы user.name проходил проверку. Найдите два разных способа — и подумайте, чем они отличаются по надёжности.
T не встречается ни в одном параметре, поэтому из аргументов вывести нечего и он падает в unknown. Подсказать тип можно с двух сторон — целью присваивания или угловыми скобками.
По надёжности они не отличаются ничем — и в этом вся соль. Оба варианта одинаково ничего не проверяют: строку никто не читал, и если в JSON приедет {}, обещание рухнет уже на запуске. Разница чисто стилистическая: аннотация переменной заодно документирует, что в ней лежит, а скобки не дают вывалиться в unknown даже в промежуточном выражении.
Настоящее решение — не обещать, а проверять: принять результат как unknown и разобрать его вручную. Это тема отдельной главы про границу программы.