Продвинутые типы
Система типов TypeScript — это не таблица аннотаций, а маленький чистый функциональный язык, который исполняется в компиляторе на этапе проверки и полностью стирается до того, как будет сгенерирован хоть один байт JavaScript. Продвинутые типы — это программирование на этом языке: keyof берёт ключи типа, mapped-тип перебирает их и строит новый тип, условный тип — это if над совместимостью, infer захватывает часть образца, а рекурсивный псевдоним вызывает сам себя. Ничего из этого не существует в рантайме: рефлексии над типами нет, as — это ничем не подкреплённое обещание компилятору, а не проверка. Держите этот тезис в голове — половина ловушек темы растёт из ожидания рантайм-семантики от чисто компиляторной машины.
Вторая половина ловушек — это места, где язык типов ведёт себя не так, как подсказывает интуиция, и их стоит назвать сразу. keyof на типе с индексной сигнатурой даёт string | number, а не string, потому что JavaScript приводит числовые ключи к строкам. Условный тип распределяется по объединению только когда проверяемый тип — голый параметр типа; обёртка в кортеж [T] extends [U] распределение выключает — именно на этом различии стоят Exclude, NonNullable и корректный IsNever. infer допустим только внутри клаузы extends, нигде больше. Подстановка объединений в template literal type раскрывается в декартово произведение — быстрый путь взорвать бюджет инстанцирования. А рекурсия упирается в жёсткий предел глубины (ts(2589), «excessively deep»), который TS 4.5 поднял с ~50 до ~1000 итераций, но только для рекурсии в хвостовой позиции. Разбор по слоям — ниже.
Карта темы
- Оператор keyof и индексированный доступ —
keyof Tкак объединение имён ключей,T[K]для безопасного чтения и почему индексная сигнатура даётstring | number. - Mapped-типы — перебор объединения ключей
{ [K in keyof T]: … }, модификаторыreadonly/?, ремаппинг ключей черезasи гомоморфность. - Условные типы —
T extends U ? X : Yкакifнад совместимостью, отложенное вычисление и определенияExclude/Extract/ReturnType. - Ключевое слово infer — захват части образца внутри
extends,infer X extends Yиз TS 4.7 и объединение против пересечения при нескольких позициях. - Дистрибутивность условных типов — распределение по голому параметру-объединению, подавление через
[T] extends [U]и ловушка распределения поnever. - Template literal types — сборка строковых литеральных типов, декартово произведение объединений,
Uppercase/Capitalizeи разбор строки черезinfer. - Рекурсивные типы — ссылающиеся на себя псевдонимы, хвостовая рекурсия по кортежам и предел глубины инстанцирования компилятора.
Частые ошибки и ловушки
| Ошибка | Последствие | |
|---|---|---|
Считать, что keyof на типе с индексной сигнатурой даёт только string | На деле `string | number — JavaScript приводит числовые ключи к строкам, поэтому number` тоже член объединения |
Ждать, что keyof увидит ключи, добавленные в рантайме | keyof видит только объявленные ключи; поэтому Object.keys типизирован string[], а не (keyof T)[] — это намеренная поблажка | |
| Думать, что mapped-тип порождает код времени выполнения | Это тип на этапе компиляции; Readonly<T> ничего не замораживает — Object.freeze тут ни при чём | |
Считать, что гомоморфный перебор сбрасывает readonly/? | Перебор поверх keyof T сохраняет модификаторы T; теряет их только не-гомоморфная форма [K in SomeUnion] | |
Читать extends в условном типе как наследование класса | Это проверка совместимости (assignability), а не наследование; 'a' extends string истинно | |
| Ждать распределения, когда проверяемый тип не голый | Распределение только при голом параметре-объединении; [T] extends [U] его выключает | |
Забывать про распределение по never | never — пустое объединение: дистрибутивный условный даёт never за ноль итераций — из-за этого ломается наивный IsNever | |
Использовать infer вне клаузы extends | infer допустим только внутри extends условного типа — в любой другой позиции это ошибка | |
| Ждать, что template literal type даст строку времени выполнения | Это набор строковых литеральных типов; подстановка объединений даёт декартово произведение | |
| Не замечать взрыв комбинаций в template literal type | Два объединения по N и M членов дают N×M членов — быстрый путь упереться в предел инстанцирования | |
| Ставить рекурсивный вызов не в хвостовую позицию | До хвостовой оптимизации глубина ~50 → ts(2589); хвостовая форма (TS 4.5) уходит до ~1000 итераций | |
| Читать «excessively deep» как настоящий бесконечный тип | Это исчерпание бюджета глубины, а не бесконечность; лечится хвостовой рекурсией, аккумулятором или счётчиком | |
Ждать типов высших порядков (F<_>) | В TypeScript их нет — параметр типа всегда обозначает конкретный тип; «любой контейнер» эмулируют реестром на строковых ключах | |
Менять нечитаемый тип на приведение as | as не проверяется в рантайме и меняет проблему читаемости на проблему корректности |
Значение для собеседований
Продвинутые типы — это senior-сигнал домена. Интервьюер проверяет не знание слов «mapped» и «conditional», а наличие модели исполнения языка типов: что именно вычисляет компилятор, в каком порядке и где у него пределы. Водораздел middle/senior — один вопрос: «когда условный тип распределяется по объединению и как это остановить?». Правильный ответ — не «всегда», а «только когда проверяемый тип — голый параметр типа; [T] extends [U] распределение выключает». Тот же приём применяют ко всей теме: вас просят воспроизвести ReturnType через infer, объяснить, почему Awaited обязан рекурсировать, и починить тип, падающий с «excessively deep», назвав настоящую причину — позицию рекурсивного вызова.
Что обычно проверяют:
- Что даёт
keyof Tи почему индексная сигнатура расширяет его доstring | number. - Как написать
Partial,Required,Readonlyруками и что делает клаузаasв mapped-типе. - Что означает
T extends U ? X : Y, что это проверка совместимости и что тип стирается из вывода. - Где допустим
inferи как через него определёнReturnType. - Когда условный тип распределяется по объединению и как выключить распределение.
- Что умеет template literal type и почему подстановка объединений даёт комбинаторный взрыв.
- Как типизировать рекурсивный
Json, почему возникает предел глубины и как хвостовая рекурсия его поднимает.
Типичный неверный ответ: «условный тип всегда распределяется по любому объединению, которое встретит». Кандидат, который так считает, не сможет объяснить, почему [T] extends [never] ? true : false — единственный корректный способ написать IsNever, а наивный T extends never ? true : false всегда даёт never (распределение по пустому объединению — ноль итераций). Второй классический провал — прочитать ошибку «Type instantiation is excessively deep» как признак того, что тип бесконечен, и «лечить» её поднятием памяти компилятора. На деле это исчерпание бюджета глубины инстанцирования: рекурсивный вызов, зашитый внутрь template literal, держит все кадры в стеке одновременно, а перенос его в хвостовую позицию через аккумулятор даёт компилятору развернуть рекурсию итеративно.