Лимиты и производительность типов
У умного кода на уровне типов есть реальная цена компиляции — лимиты глубины и количества инстанцирований, взрыв декартова произведения объединений, лаг редактора — и senior умеет её измерять и знает, когда остановиться и валидировать в рантайме.
Умный инженер выкатывает «полностью типизированный» построитель SQL-запросов. Типы прекрасны — имена колонок, join’ы и формы возврата выводятся сами. Через три спринта вся команда заводит тикеты: автодополнение тянется четыре секунды, редактор пригвождает ядро CPU, а шаг tsc в CI вырос с 40 секунд до девяти минут. Ничего не «сломано» — каждый тип корректен. Цена невидима, потому что живёт целиком на этапе компиляции, и её платит каждый разработчик на каждом нажатии клавиши. Навык senior — не написать умный тип. Это знать его цену, измерять её через --generateTrace и решать, когда правильный ход — удалить тип и валидировать в рантайме.
Два жёстких лимита
Компилятор навязывает два потолка, дающих одно и то же сообщение об ошибке:
- Глубина инстанцирования — насколько глубоко может вкладываться одна цепочка инстанцирований типов. Не-хвостовая рекурсия упирается около 50; хвостово-рекурсивные условные типы (TS 4.5+) достигают примерно 1000.
- Количество инстанцирований — глобальный бюджет на то, сколько инстанцирований типов может запустить одно выражение. Компилятор прерывает разрастающийся тип с
Type instantiation is excessively deep and possibly infinite., защищаясь от типов, которые иначе зависнут проверку навсегда.
Сообщение не различает «действительно бесконечный» и «конечный, но слишком дорогой». Оба всплывают одинаково, поэтому диагностика — это сначала проверить базовый случай, затем глубину, затем общее число инстанцирований.
Настоящий убийца: декартовы произведения объединений
Глубина редко то, что кусает команды в проде. Катастрофическая цена — комбинаторный взрыв объединений. Когда два объединения встречаются так, что компилятор вынужден материализовать каждую пару, работа — это произведение их размеров:
type Pair<A, B> = A extends any ? (B extends any ? [A, B] : never) : never;
type U = "a" | "b" | "c" | "d"; // 4 члена
type Cross = Pair<U, U>; // 4 x 4 = 16 членов
// сцепите три таких и получите 4^3 = 64; десять объединений по 10 -> 10^10 инстанцированийДистрибутивное условие над 50-членным объединением, вложенное в другое дистрибутивное условие над 50-членным объединением, — это 2 500 инстанцирований для одного псевдонима типа. Сложите несколько таких — и дойдёте до миллионов. Вот почему невинно выглядящий тип может в 10 раз увеличить лаг редактора всем.
Измерять, а не гадать
Никогда не оптимизируйте типы на ощупь. Компилятор выдаёт точные числа:
# Куда уходит время? Итоги: parse, bind, check, счётчики инстанцирований.
tsc --noEmit --extendedDiagnostics
# Снять профиль, открываемый в chrome://tracing или анализатором.
tsc --noEmit --generateTrace ./trace
npx @typescript/analyze-trace ./trace # ранжирует самые горячие типы и файлы
# Не дать компилятору прятать виновный тип за "..." в ошибках.
tsc --noEmit --noErrorTruncation--extendedDiagnostics показывает Instantiations: и Check time — скачок с 200k до 5M инстанцирований после PR указывает прямо на виновника. --generateTrace плюс @typescript/analyze-trace называет конкретный псевдоним типа и место в исходнике, жгущее больше всего времени. --noErrorTruncation раскрывает полный (часто гигантский) тип, на котором компилятор давится, вместо бесполезного ....
▸Почему это работает
Почему интерфейсы часто проверяются быстрее больших пересечений типов? Объявление interface получает имя, кэшируется и сравнивается по этому имени (и по структуре лениво), поэтому повторные ссылки переиспользуют работу. Большое анонимное пересечение A & B & C & ... заново элаборируется, а его свойства сливаются в каждом месте использования, без имени для кэширования. Для горячей, часто упоминаемой формы объявление её интерфейсом (или именование промежуточного псевдонима, чтобы компилятор мемоизировал) может существенно срезать время проверки. Performance-вики TypeScript от Microsoft рекомендует предпочитать интерфейсы и базовые типы большим пересечениям именно по этой причине.
Митигации, по убыванию рычага
- Сократите входы. Избегайте дистрибуции по большим объединениям; отключайте обёрткой
[T]там, где не нужно по-членное поведение. - Кэшируйте промежуточное. Дайте рекурсивному или составному типу именованный псевдоним, чтобы компилятор мемоизировал его, а не пересчитывал инлайн на каждой ссылке.
- Предпочитайте интерфейсы большим пересечениям для горячих форм; они кэшируются по имени.
- Избегайте глубоких цепочек условий. Уплощайте длинные лестницы
A extends X ? ... : B extends Y ? ...; они множат число инстанцирований. - Включите
--incremental(и project references), чтобы неизменные файлы переиспользовали прежний.tsbuildinfo, и перепроверялся только затронутый граф. - Остановитесь и валидируйте в рантайме. Ход senior: если тип стоит команде больше, чем экономит, удалите его и проверяйте инвариант в рантайме (валидатор схемы, парсер, рантайм-гард). 200-строчный тип, доказывающий, что строка — валидный маршрут, стоит меньше 5-строчной рантайм-проверки, делающей то же и ничего не стоящей редактору.
Вместе эти шесть рычагов движутся от самого дешёвого фикса (сократить входы, без структурных изменений) до самого решительного (удалить тип полностью). Если шаг 1 не помог — скорее всего, перед вами структурная проблема, которую решат только шаги 4–6.
PR добавляет дистрибутивное условие над 60-членным объединением, вложенное в другое дистрибутивное условие над 40-членным объединением. Время tsc в CI утраивается. Какова доминирующая цена и какое измерение запустить первым?
Расставьте шаги диагностики и устранения внезапного замедления tsc после тяжёлого по типам PR.
- 1 Запустить tsc --noEmit --extendedDiagnostics и сравнить Instantiations / Check time с базовой линией
- 2 Запустить tsc --noEmit --generateTrace ./trace, чтобы снять профиль
- 3 Запустить @typescript/analyze-trace ./trace, чтобы ранжировать самые горячие типы и места в исходнике
- 4 Осмотреть названный тип с --noErrorTruncation, чтобы увидеть его полное разворачивание
- 5 Применить митигацию (сузить объединения, закэшировать псевдоним или удалить тип и валидировать в рантайме)
- 01Какие два разных лимита компилятора стоят за 'Type instantiation is excessively deep and possibly infinite.' и как понять, в какой вы упёрлись?
- 02Почему декартовы произведения объединений — доминирующий риск производительности в коде на уровне типов и как их сдерживать?
- 03Назовите инструменты измерения и лестницу митигаций и сформулируйте правило, когда вовсе отказаться от типа.
Этот юнит строил программы на уровне типов; этот урок — отрезвление по их цене. Два жёстких лимита — глубина инстанцирования (~50 не-хвост, ~1000 хвост) и глобальный бюджет числа инстанцирований — оба сообщают «Type instantiation is excessively deep and possibly infinite.», а настоящий продакшен-убийца — декартовы произведения объединений, чья цена это произведение размеров. Измеряйте, а не гадайте, через --extendedDiagnostics, --generateTrace плюс @typescript/analyze-trace и --noErrorTruncation; затем поднимайтесь по лестнице митигаций — сузить объединения, кэшировать псевдонимы, предпочесть интерфейсы, уплощать условия, включить --incremental — и, когда тип стоит команде больше, чем экономит, валидируйте в рантайме. Теперь, когда после тяжёлого по типам PR автодополнение начнёт тормозить, вы тянетесь к --generateTrace первым — а не к рерайту.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
встречается в1
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.