Вы пишете тип, который обходит вложенные объекты, выстраивая пути через точку для автодополнения. На небольшом тестовом объекте всё работает великолепно. Но стоит направить его на реальную полезную нагрузку (payload) API, как редактор зависает. В конце концов TypeScript выдает ошибку TS2589: Type instantiation is excessively deep and possibly infinite.

Это сообщение не означает, что в вашем коде содержится бесконечный цикл в традиционном понимании. Это означает, что компилятор сдался. Тип, который вы просили его вычислить, был либо действительно неограниченным, либо конечным, но настолько огромным, что его вычисление исчерпало внутренние лимиты TypeScript. Когда это происходит, компилятор останавливается прежде, чем ваша IDE зависнет окончательно.

Когда появляется TS2589

Рекурсивные типы — самый частый виновник. TypeScript вычисляет типы жадно (eagerly), и если вспомогательный тип постоянно вызывает сам себя — особенно через условную логику — стек вычислений растет очень быстро. Обычно вы сталкиваетесь с этой проблемой в нескольких конкретных сценариях:

  • Рекурсивные условные типы, которые многократно деструктурируют кортеж, объект или шаблон строки до достижения базового случая
  • Генераторы путей для глубоко вложенных объектов, которые превращают структуры вроде { user: { address: { street: string } } } в объединения (unions) строковых литералов, таких как "user" | "user.address" | "user.address.street"
  • Типы шаблонных литералов, которые разбирают строки посимвольно или по токенам
  • Сопоставленные типы (Mapped types), итерируемые по объектам с десятками ключей и множеством уровней вложенности
  • Условные типы, которые распределяются (distribute) по большим объединениям, незаметно умножая нагрузку на каждый элемент

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

Решение первое: Добавьте жесткий лимит глубины

Самый прямой способ решить TS2589 — перестать делать вид, что ваш тип может рекурсивно вызываться бесконечно. Введите счетчик глубины, который будет работать как автоматический выключатель (circuit breaker).

На практике это означает добавление числового параметра типа (generic parameter) — часто представленного в виде кортежа, длина которого уменьшается при каждом рекурсивном вызове. Когда счетчик достигает нуля, тип возвращает общее значение, например string, вместо того чтобы продолжать углубляться. Пользователи по-прежнему будут получать точное автодополнение для первых четырех или пяти уровней, что покрывает подавляющее большинство реальных объектов. За пределами этого компилятор просто расширяет тип и идет дальше.

Этот подход не делает ваш вспомогательный тип менее правильным в существенном смысле. Он делает его ограниченным. Система типов, которая обрушивает компилятор, не полезнее той, которая изящно уступает после достижения разумной глубины.

Решение второе: Валидируйте по одному пути за раз

Если предварительная генерация всех возможных путей обходится слишком дорого, измените контракт. Вместо создания массивного объединения всех допустимых строк напишите тип, который проверяет, является ли одна конкретная строка валидным путем.

Представьте разницу между созданием словаря всех английских слов и проверкой правильности написания одного слова. Первое — это огромная структура данных; второе — легкая проверка. В терминах TypeScript вместо экспорта утилиты Paths<T>, которая выдает "user.address.street" | "user.settings.theme" | ..., вы экспортируете что-то вроде IsValidPath<T, "user.address.street">. Компилятор будет вычислять только тот путь, который вы передаете на самом деле.

Такой сдвиг меняет подход к проектированию API. Сигнатуры ваших функций могут принимать строку, а затем использовать ограничение типа (generic constraint), чтобы проверить её на соответствие структуре объекта. IDE по-прежнему будет выдавать ошибку, если разработчик введет неверный путь, но компилятору никогда не придется материализовать полный набор допустимых путей во время проверки типов. Для больших объектов разница в производительности будет колоссальной.

Быстрые тактики, которые помогут двигаться дальше

Помимо двух структурных решений, несколько небольших привычек помогут не дать рекурсивным типам перейти черту:

  • Оборачивайте параметры типов в кортежи, чтобы заблокировать распределение. «Голый» параметр типа в условном типе, например T extends Foo ? Bar : Baz, распределяет проверку по каждому элементу, если T является объединением (union). Если в этом объединении пятьдесят элементов, TypeScript выполнит пятьдесят отдельных инстанцирований. Запись [T] extends [Foo] ? Bar : Baz вычисляет условие один раз для всего объединения целиком. Используйте этот прием везде, где вам не требуется, чтобы тип проходил по каждому элементу объединения по отдельности.

  • Уменьшайте входные данные при отладке. Когда появляется ошибка TS2589, замените ваш рабочий тип объекта на крошечную заглушку с двумя свойствами и одним уровнем вложенности. Если ошибка исчезает, вы подтверждаете, что проблема заключается в глубине или количестве элементов, а не в синтаксической ошибке. Это убережет вас от переписывания логики, которая на самом деле была структурно верной.

  • Делайте типы публичного API менее строгими. Внутри проекта вам может потребоваться хирургическая точность. Снаружи же стремление к совершенству иногда обходится дороже, чем приносит пользы. Если чуть более широкий тип для автодополнения предотвратит двухсекундную задержку в редакторе, этот компромисс обычно того стоит. Вы можете сочетать менее строгий тип с валидатором во время выполнения (runtime validator), чтобы отлавливать некорректные пути при тестировании.

Почему TypeScript устанавливает это ограничение

TypeScript не может решить проблему остановки. Он не знает, завершится ли ваш рекурсивный тип в итоге или будет уходить в бесконечный цикл. Вместо того чтобы рисковать бесконечным циклом внутри компилятора, он применяет консервативный порог отсечения. Иногда этот порог отсекает типы, которые могли бы завершиться, будь у компилятора больше времени. TS2589 — это признание компилятора в том, что он предпочитает перестраховаться.

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

Главный вывод

TS2589 — это не сигнал о том, что вы плохой программист систем типов. Это сигнал о том, что ваш тип выполняет слишком много работы одновременно. Ограничивайте рекурсию, используйте ленивую валидацию и защищайтесь от ненужного распределения. Цель продвинутых типов не в том, чтобы доказать каждую возможную истину на этапе компиляции, а в том, чтобы обеспечить вашу команду быстрым и надежным инструментарием. Тип, который компилируется за миллисекунды и покрывает девяносто пять процентов случаев, гораздо ценнее того, который теоретически идеален, но вызывает сбой языкового сервера.