open atlas
↑ К треку
Система типов TypeScript вглубь TS · 05 · 01

Рекурсия в типах

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

TS Senior ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Вы пишете типы для крошечной роутер-библиотеки. Путь "/users/:id/posts/:slug" должен давать { id: string; slug: string } — автоматически, на этапе компиляции, без шага кодогенерации. Чтобы туда добраться, нужно, чтобы система типов прошла структуру элемент за элементом и накапливала результат. Это рекурсия, а компилятор — ваш интерпретатор. Он с радостью выполнит вашу типовую программу — ровно до того момента, когда напечатает Type instantiation is excessively deep and possibly infinite. и сдастся. Понимание того, где стоит эта стена и как TypeScript 4.5 её сдвинул, отделяет умный тип от поставленного в прод.

Через десять минут вы будете точно знать, почему компилятор сдаётся, и как отодвинуть стену с 50 уровней до 1000.

Форма любого рекурсивного типа

У рекурсивного условного типа ровно две ветки, как у корректной рекурсивной функции:

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

«Меньший вход» — это почти всегда кортеж с отрезанной головой. Главный рычаг — [infer Head, ...infer Tail]. В ветке extends условного типа этот паттерн матчит непустой кортеж, связывает первый элемент с Head, а всё, что после него, — с Tail. Базовый случай — пустой кортеж [], который не проходит проверку extends и проваливается в ветку else.

Reverse: канонический обход

type Reverse<T extends readonly unknown[]> =
  T extends readonly [infer Head, ...infer Tail]
    ? [...Reverse<Tail>, Head]
    : [];

type R = Reverse<[1, 2, 3]>;
//   ^? type R = [3, 2, 1]

Читайте это как трассировку. Reverse<[1,2,3]> матчится, Head = 1, Tail = [2,3], поэтому превращается в [...Reverse<[2,3]>, 1]. Это рекурсирует в [...Reverse<[3]>, 2, 1], затем [...Reverse<[]>, 3, 2, 1], и Reverse<[]> попадает в базовый случай []. Накопленные спреды схлопываются в [3, 2, 1].

Join и Includes — тот же скелет, другое накопление

type Join<T extends readonly string[], D extends string> =
  T extends readonly [infer H extends string, ...infer R extends string[]]
    ? R extends [] ? H : `${H}${D}${Join<R, D>}`
    : "";

type J = Join<["a", "b", "c"], "-">;
//   ^? type J = "a-b-c"

type Includes<T extends readonly unknown[], U> =
  T extends readonly [infer H, ...infer R]
    ? [H] extends [U] ? [U] extends [H] ? true : Includes<R, U> : Includes<R, U>
    : false;

type Inc = Includes<[1, 2, 3], 2>;
//   ^? type Inc = true

Join несёт разделитель D вдоль рекурсии и обрабатывает последний элемент особым случаем (R extends []), чтобы строка не заканчивалась лишним разделителем. Includes коротко замыкается в момент, когда находит совпадение — рекурсивный вызов происходит только в ветке «ещё не найдено». (Обратите внимание на обёртки [H] extends [U] и [U] extends [H] — это отключает дистрибуцию, чтобы сравнение было точным; с этим приёмом вы встретитесь в следующем уроке.)

Почему это работает

Почему infer H extends string? Ограничение на H позволяет сразу использовать H в шаблонном литерале ${H} без второго условия, доказывающего, что это строка. Ограниченный infer (TS 4.7+) — это то, как сохраняют рекурсивные строковые типы читаемыми, а не вкладывают H extends string ? ... : never на каждом шаге.

Стена глубины — и почему важна хвостовая позиция

Когда вы видите «Type instantiation is excessively deep and possibly infinite.» (ошибку «слишком глубокое инстанцирование типа, возможно бесконечное»), первый вопрос — это неверный базовый случай или просто не-хвостовой вызов. Одно сообщение покрывает оба, а фикс разный. Компилятор ограничивает рекурсию, чтобы проверка типов не зависала. Исторически рекурсивный условный тип взрывался около 50 уровней самоссылки с ошибкой:

type BuildBad<N extends number, Acc extends unknown[] = []> =
  Acc["length"] extends N ? Acc : [unknown, ...BuildBad<N, [unknown, ...Acc]>];
//                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Error: Type instantiation is excessively deep and possibly infinite.
// (рекурсивный вызов обёрнут в [unknown, ...] — НЕ в хвостовой позиции)

TypeScript 4.5 добавил устранение хвостовой рекурсии для условных типов. Когда рекурсивный вызов — это весь результат ветки условия (вокруг него ничего нет), компилятор переиспользует кадр вычисления вместо вложения, и эффективный лимит подскакивает примерно до 1000. Фикс — протащить аккумулятор и вернуть его напрямую:

type Build<N extends number, Acc extends unknown[] = []> =
  Acc["length"] extends N
    ? Acc                                     // базовый случай
    : Build<N, [unknown, ...Acc]>;            // ХВОСТОВОЙ вызов: вся ветка ЕСТЬ рекурсия

type T999 = Build<999>["length"];
//   ^? type T999 = 999   (без ошибки — устранение хвостовой рекурсии)

Структурная разница: [unknown, ...Build<...>] оборачивает вызов, поэтому каждый кадр должен ждать внутренний результат — не устраняется. Build<N, [unknown, ...Acc]> и есть результат — устраняется. Перестройте рекурсию так, чтобы самовызов стоял в хвостовой позиции, и стена сдвинется с 50 до 1000.

Викторина

Почему `type Bad<T> = T extends [infer H, ...infer R] ? [Bad<R>, H] : []` упирается в лимит глубины намного раньше хвостовой версии?

Расставь шаги по порядку

Расставьте вычисление Reverse<[1, 2, 3]> от первого матча до итогового схлопнутого результата.

  1. 1 Reverse<[1,2,3]> матчится, Head=1, Tail=[2,3] -> [...Reverse<[2,3]>, 1]
  2. 2 Reverse<[2,3]> матчится, Head=2, Tail=[3] -> [...Reverse<[3]>, 2, 1]
  3. 3 Reverse<[3]> матчится, Head=3, Tail=[] -> [...Reverse<[]>, 3, 2, 1]
  4. 4 Reverse<[]> попадает в базовый случай и возвращает []
  5. 5 Спреды схлопываются в итоговый тип [3, 2, 1]
Вспомните перед уходом
  1. 01
    Какие две ветки обязаны быть у любого корректного рекурсивного условного типа и что делает паттерн [infer Head, ...infer Tail] в рекурсивной ветке?
  2. 02
    Что такое устранение хвостовой рекурсии для условных типов, когда оно применяется и как меняет лимит глубины?
  3. 03
    Что именно вызывает ошибку 'Type instantiation is excessively deep and possibly infinite.' и какие две разные корневые причины надо проверить?
Итог

Рекурсивный тип — это рекурсивная функция, которую выполняет компилятор: базовый случай плюс рекурсивный случай, отрезающий [infer Head, ...infer Tail] и вызывающий себя на укороченном кортеже. Reverse, Join и Includes имеют этот скелет, различаясь лишь тем, что накапливают. Компилятор ограничивает глубину: не-хвостовой вызов упирается около 50 с «Type instantiation is excessively deep and possibly infinite.», но устранение хвостовой рекурсии из TypeScript 4.5 поднимает это до ~1000, когда самовызов — вся ветка. Теперь, встретив эту ошибку, вы первым делом проверяете базовый случай, а затем позицию самовызова — два вопроса, которые покрывают каждый случай.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.