Продвинутые типы
Уровень программирования на типах — условные типы и infer, mapped-типы поверх keyof, template literal types, дистрибутивность условных типов и рекурсивные типы с их ограничением глубины инстанцирования.
16 вопросов
JuniorТеорияОчень частоЧто такое условный тип и что означает T extends U ? X : Y?
Что такое условный тип и что означает T extends U ? X : Y?
Условный тип выбирает один из двух типов, проверяя совместимость: T extends U ? X : Y даёт X, если T совместим с U, и Y в противном случае. Это if уровня типов, вычисляемый компилятором и полностью стираемый из итогового JavaScript.
Типичные ошибки
- ✗Читать
extendsздесь как наследование класса, а не как проверку совместимости - ✗Ожидать, что условный тип попадёт в итоговый JavaScript
- ✗Считать, что обе ветки объединяются, а не выбирается ровно одна
Уточняющие вопросы
- →Что происходит с условным типом, когда проверяемый
T— объединение? - →Что добавляет ключевое слово
inferвнутри клаузыextends?
JuniorТеорияОчень частоЧто даёт keyof T для объектного типа?
Что даёт keyof T для объектного типа?
keyof T даёт объединение имён свойств T как литеральных типов: для { a: number; b: string } это 'a' | 'b'. Вместе с индексированным доступом T[K] он типизирует безопасное чтение свойства, как в <K extends keyof T>(o: T, k: K) => T[K]. Для типа с строковой индексной сигнатурой он расширяется до string | number.
Типичные ошибки
- ✗Думать, что
keyof Tдаёт типы значений, а не имена свойств - ✗Ожидать обычный
stringвместо объединения литеральных имён ключей - ✗Забывать, что строковая индексная сигнатура расширяет
keyof Tдоstring | number
Уточняющие вопросы
- →Как работает индексированный доступ
T[K], когдаKограничен черезkeyof T? - →Что даёт
keyofдля объединения вродеA | B?
JuniorТеорияОчень частоЧто такое mapped-тип и как работает { [K in keyof T]: T[K] }?
Что такое mapped-тип и как работает { [K in keyof T]: T[K] }?
Mapped-тип строит новый объектный тип, перебирая объединение ключей. { [K in keyof T]: T[K] } проходит по каждому ключу T и задаёт ему тип значения, воспроизводя T. Readonly<T> и Partial<T> написаны ровно так, добавляя при переборе модификатор readonly или ?. Результат — свежий тип, вычисленный компилятором.
Типичные ошибки
- ✗Считать, что mapped-тип порождает код времени выполнения, а не тип на этапе компиляции
- ✗Думать, что тип значения нельзя преобразовать во время перебора
- ✗Путать mapped-тип с индексной сигнатурой — набор ключей фиксирован, а не открыт
Уточняющие вопросы
- →Как самому написать
Readonly<T>иPartial<T>как mapped-типы? - →Как клауза
asпозволяет mapped-типу переименовать или отбросить ключи?
JuniorКодЧастоКак типизировать JSON-значение с вложенностью объектов и массивов любой глубины?
Как типизировать JSON-значение с вложенностью объектов и массивов любой глубины?
Псевдоним типа может ссылаться на себя, поэтому Json — это рекурсивное объединение: type Json = string | number | boolean | null | Json[] | { [k: string]: Json }. Обе ссылки на себя стоят внутри массива или индексной сигнатуры, что откладывает рекурсию, поэтому компилятор разворачивает её лениво и вложенность работает на любую глубину.
Типичные ошибки
- ✗Считать, что псевдоним типа не может ссылаться на себя
- ✗Тянуться к
anyилиunknownвместо того, чтобы выразить рекурсию напрямую - ✗Протаскивать самодельный счётчик глубины, не нужный лениво разворачиваемому псевдониму
Уточняющие вопросы
- →Что изменится, если объявить
Jsonкакinterface, а не как псевдоним типа? - →Когда рекурсивный тип упирается в предел глубины инстанцирования компилятора?
JuniorТеорияЧастоЧто такое template literal type и что он умеет выражать?
Что такое template literal type и что он умеет выражать?
Template literal type строит строковые литеральные типы из других типов и пишется как шаблонная строка JS, но в позиции типа. Подстановка объединения раскрывается в декартово произведение, поэтому сочетание 'a' | 'b' и 1 | 2 даёт 'a-1' | 'a-2' | 'b-1' | 'b-2'. Вместе с Capitalize и infer он может собирать и даже разбирать имена ключей на этапе компиляции.
Типичные ошибки
- ✗Думать, что он даёт строку времени выполнения, а не набор литеральных типов
- ✗Не понимать, что подставленное объединение раскрывается в декартово произведение
- ✗Забывать, что
inferумеет сопоставлять образец внутри template literal type и разбирать строку
Уточняющие вопросы
- →Как типизировать имя обработчика, выведенное из объединения имён событий?
- →Как
inferвнутри template literal type позволяет разбить строку?
MiddleТеорияЧастоКогда условный тип распределяется по объединению и как это остановить?
Когда условный тип распределяется по объединению и как это остановить?
Условный тип распределяется, когда проверяемый тип — голый параметр типа, а аргумент — объединение: проверка выполняется для каждого члена, а результаты снова объединяются. Поэтому NonNullable<string | null> даёт string. Обёртка обеих сторон в кортеж — [T] extends [U] ? X : Y — делает операнд не голым, и объединение проверяется целиком за один раз.
Типичные ошибки
- ✗Ожидать распределения, когда проверяемый тип — не голый параметр типа
- ✗Удивляться, что аргумент-объединение даёт объединение результатов, а не одну ветку
- ✗Не знать, что обёртка в кортеж
[T] extends [U]и отключает распределение
Уточняющие вопросы
- →Что даёт дистрибутивный условный тип, когда
T— этоnever? - →Как распределение объясняет определение
Exclude<U, E>?
MiddleТеорияЧастоЧто делает infer внутри клаузы extends условного типа?
Что делает infer внутри клаузы extends условного типа?
infer R объявляет свежую переменную типа в этой позиции образца и связывает её с тем, что компилятор там сопоставил. T extends (...a: any[]) => infer R ? R : never захватывает тип возврата функции — именно так и определён ReturnType<T>. infer допустим только внутри клаузы extends условного типа, а связанная переменная видна лишь в истинной ветке.
Типичные ошибки
- ✗Пытаться использовать
inferвне клаузыextendsусловного типа - ✗Обращаться к выведенной переменной в ложной ветке, где её нет в области видимости
- ✗Ожидать, что
inferрасширит совпадение, а не свяжет его точно
Уточняющие вопросы
- →Как написан
Awaited<T>и почему ему приходится рекурсировать? - →Что происходит, когда одна позиция
inferсовпадает сразу в нескольких местах?
MiddleТеорияЧастоЧто делают клауза as и модификаторы +/- в mapped-типе?
Что делают клауза as и модификаторы +/- в mapped-типе?
Клауза as переименовывает ключи: сопоставление K с template literal type даёт новое имя, а сопоставление с never полностью отбрасывает ключ. Модификаторы +/- добавляют или снимают readonly и ? — через -? и определён Required<T>. Гомоморфный перебор, записанный поверх keyof T, переносит существующие readonly и ? из T, пока вы их явно не переопределите.
Типичные ошибки
- ✗Думать, что
asменяет тип значения, а не переименовывает ключ - ✗Упускать, что сопоставление ключа с
never— это и есть способ его отбросить - ✗Считать, что гомоморфный перебор сбрасывает
readonly/?, а не сохраняет их
Уточняющие вопросы
- →Как построить тип с геттером на каждое свойство, например
getNameизname? - →Что делает mapped-тип гомоморфным и почему это важно для массивов?
MiddleКодИногдаТипизируйте объект опций, где два поля должны быть либо оба, либо ни одного
Типизируйте объект опций, где два поля должны быть либо оба, либо ни одного
Объедините полную форму с формой, все ключи которой необязательны и типа never: type AllOrNothing<T> = T | { [K in keyof T]?: never }. Вторая половина — mapped-тип поверх keyof T, поэтому она следует за T сама. Передача только start не подходит ни к одной ветке — в первой нет end, а во второй значение получает never, — и компилятор её отвергает.
Типичные ошибки
- ✗Считать, что два необязательных поля уже ограничивают друг друга — это не так
- ✗Пересекать половины вместо объединения, получая непригодный тип
- ✗Сдаваться и уносить инвариант в проверку во время выполнения
Уточняющие вопросы
- →Как расширить это до исключающего «или» между двумя разными формами?
- →Почему проверка лишних свойств важна здесь для ветки с пустым объектом?
SeniorДебаггингИногдаНайдите и исправьте тип, падающий с «instantiation is excessively deep»
Найдите и исправьте тип, падающий с «instantiation is excessively deep»
Рекурсивный вызов стоит внутри template literal, то есть не в хвостовой позиции: каждый уровень остаётся в стеке инстанцирования, ожидая свой результат, и бюджет глубины кончается. Протащите аккумулятор, чтобы ветка возвращала рекурсивный вызов напрямую, склеивая строку до рекурсии — такую хвостовую форму компилятор оптимизирует и раскручивает намного глубже.
Открыть задачу →Типичные ошибки
- ✗Винить спред кортежа или ограничения
inferвместо позиции самого вызова - ✗Не распознавать, что рекурсивный вызов внутри template literal — не хвостовой
- ✗Ограничивать длину входа вместо перевода рекурсии в хвостовую позицию
Уточняющие вопросы
- →Почему параметр-аккумулятор превращает это в хвостовой рекурсивный тип?
- →Как сохранить публичную сигнатуру из двух параметров, рекурсируя по трём?
SeniorТеорияИногдаПочему перебор по A | B отличается от перебора по keyof (A | B)?
Почему перебор по A | B отличается от перебора по keyof (A | B)?
Гомоморфный mapped-тип — { [K in keyof T]: … } с голым параметром T — распределяется по объединению, поэтому Partial<A | B> превращается в Partial<A> | Partial<B>, и каждый член сохраняет свои ключи. Перебор по keyof (A | B) не гомоморфен: keyof от объединения даёт лишь ключи, общие для всех членов, поэтому результат схлопывается до общих ключей и молча теряет остальные.
Типичные ошибки
- ✗Ожидать, что
keyof (A | B)даст все ключи, а не только общие - ✗Не понимать, что гомоморфный mapped-тип распределяется по аргументу-объединению
- ✗Встроить
keyof Tв источник ключей и случайно потерять распределение
Уточняющие вопросы
- →Во что вычисляется
keyof (A | B), если уAиBвовсе нет общих ключей? - →Как это взаимодействует с размеченным объединением, члены которого различаются формой?
SeniorТеорияИногдаПочему рекурсивный условный тип перестаёт компилироваться при росте входа?
Почему рекурсивный условный тип перестаёт компилироваться при росте входа?
Компилятор ограничивает глубину инстанцирования типа, за которой считает разворачивание потенциально бесконечным, и условный тип, раскручивающийся по разу на элемент, упирается в предел с ошибкой «Type instantiation is excessively deep and possibly infinite». Рекурсия в хвостовой позиции оптимизирована и уходит намного дальше, поэтому лечится это переводом в хвостовую форму, обработкой пачками или счётчиком глубины.
Типичные ошибки
- ✗Читать ошибку как настоящий бесконечный тип, а не как исчерпание бюджета глубины
- ✗Не знать, что хвостовые рекурсивные условные типы получают куда больший бюджет
- ✗Пытаться поднять лимит памяти компилятора вместо перестройки рекурсии
Уточняющие вопросы
- →Что делает рекурсивный условный тип хвостовым с точки зрения компилятора?
- →Как параметр-аккумулятор меняет глубину, которой достигает рекурсия типов?
SeniorКодИногдаРеализуйте ReturnType<T> сами, затем объясните, почему Awaited<T> рекурсивен
Реализуйте ReturnType<T> сами, затем объясните, почему Awaited<T> рекурсивен
type MyReturnType<T extends (...a: any) => any> = T extends (...a: any) => infer R ? R : never — условный тип сопоставляет T с формой функции, а infer R связывает позицию возврата. Awaited<T> на этом остановиться не может: промис может разрешиться в другой промис, поэтому он применяет себя к выведенному значению — T extends PromiseLike<infer U> ? MyAwaited<U> : T — пока нагрузка ещё thenable.
Типичные ошибки
- ✗Считать
ReturnTypeвстроенным, а не условным типом сinfer - ✗Писать
Awaitedкак одноразовое разворачивание, оставляяPromise<Promise<T>>недоразвёрнутым - ✗Класть выведенную переменную в список параметров псевдонима вместо клаузы
extends
Уточняющие вопросы
- →Как тем же приёмом написать
Parameters<T>? - →Что настоящий
Awaited<T>добавляет сверх этого для не-промисного thenable?
SeniorДизайнРедкоВ общем пакете типов вашей команды разросся слой глубоко вложенных условных типов с несколькими позициями infer в каждом. Они корректны и покрыты тестами, но ошибка компиляции внутри одного из них печатает развёрнутый тип на 40 строк, который никто не может прочитать, подсказки IDE показывают неразвёрнутый псевдоним вместо разрешённой формы, а два разработчика уже поставили баги, неверно поняв, какая ветка срабатывает. Джуниор предлагает заменить весь слой горсткой конкретных типов, написанных руками, плюс несколько приведений. Как вы решите, где программирование на типах окупается, а где его надо упростить, и что измените, не выбрасывая типобезопасность?
В общем пакете типов вашей команды разросся слой глубоко вложенных условных типов с несколькими позициями infer в каждом. Они корректны и покрыты тестами, но ошибка компиляции внутри одного из них печатает развёрнутый тип на 40 строк, который никто не может прочитать, подсказки IDE показывают неразвёрнутый псевдоним вместо разрешённой формы, а два разработчика уже поставили баги, неверно поняв, какая ветка срабатывает. Джуниор предлагает заменить весь слой горсткой конкретных типов, написанных руками, плюс несколько приведений. Как вы решите, где программирование на типах окупается, а где его надо упростить, и что измените, не выбрасывая типобезопасность?
Судите каждый тип по тому, устраняет ли он реальный класс ошибок: сложный тип окупается на широко используемой границе, а не в листе. Разбейте монолиты на маленькие именованные псевдонимы, чтобы ошибки и подсказки называли осмысленное имя, добавьте тесты уровня типов на каждую ветку и держите рекурсию неглубокой. Приведения отклоните — они меняют проблему читаемости на проблему корректности.
Типичные ошибки
- ✗Считать нечитаемые типы проблемой инструментов, а не сигналом о дизайне
- ✗Менять логику на уровне типов на приведения, теряя корректность ради читаемости
- ✗Оценивать сложный тип по элегантности, а не по багам, которые он реально предотвращает
Уточняющие вопросы
- →Что тесты уровня типов фиксируют такого, чего не может тест времени выполнения?
- →Как именование промежуточного псевдонима меняет то, что печатает ошибка компиляции?
SeniorДизайнРедкоВы отвечаете за продукт, чей каталог переводов — глубоко вложенный объектный литерал: секции, подсекции и ключи сообщений, несколько сотен листьев, оформленных одним модулем с as const. Сегодня помощник поиска принимает путь как string, например 'checkout.errors.expiredCard', поэтому опечатка компилируется чисто и всплывает как отсутствующий перевод в проде; переименование секции молча ломает все места вызова. Команда хочет, чтобы компилятор отвергал неизвестный путь, подсказывал валидные и выводил, какие аргументы подстановки нужны каждому сообщению. Опишите проектирование на уровне типов и ограничения, в которые вы упрётесь.
Вы отвечаете за продукт, чей каталог переводов — глубоко вложенный объектный литерал: секции, подсекции и ключи сообщений, несколько сотен листьев, оформленных одним модулем с as const. Сегодня помощник поиска принимает путь как string, например 'checkout.errors.expiredCard', поэтому опечатка компилируется чисто и всплывает как отсутствующий перевод в проде; переименование секции молча ломает все места вызова. Команда хочет, чтобы компилятор отвергал неизвестный путь, подсказывал валидные и выводил, какие аргументы подстановки нужны каждому сообщению. Опишите проектирование на уровне типов и ограничения, в которые вы упрётесь.
Сохраните каталог с as const, чтобы литералы уцелели, затем выведите объединение ключей рекурсивным типом, который обходит объект и собирает точечные пути через template literal type. Индексированный доступ возвращает по пути литерал сообщения, а вторая рекурсия разбирает из него плейсхолдеры в объект аргументов. Ждите пределов глубины инстанцирования и размера объединения — ограничьте вложенность, разбейте большие каталоги.
Типичные ошибки
- ✗Ожидать, что
keyofспустится во вложенные объекты и даст точечные пути - ✗Считать, что тип не умеет обходить вложенный объект, и потому кодогенерация единственна
- ✗Игнорировать пределы глубины инстанцирования и размера объединения на большом каталоге
Уточняющие вопросы
- →Почему каталог обязан остаться с
as const, чтобы всё это работало? - →Как разбить каталог, когда объединение ключей перерастёт пределы компилятора?
SeniorТеорияРедкоПочему параметр типа не может сам быть дженериком и что делают библиотеки?
Почему параметр типа не может сам быть дженериком и что делают библиотеки?
В TypeScript нет типов высших порядков: параметр типа всегда обозначает конкретный тип, а не неприменённый конструктор типов, поэтому нельзя написать F<_> и затем применить его как F<string>. Абстрагироваться над «любым контейнером» напрямую невозможно. Библиотеки подменяют это дефункционализацией — регистрируют конструктор под строковым ключом в интерфейсе, передают ключ и применяют индексированным доступом.
Типичные ошибки
- ✗Считать, что
inferзахватывает неприменённый конструктор типов, а не только его аргумент - ✗Думать, что псевдоним типа снимает ограничение, которое есть у интерфейса
- ✗Читать приём «реестр плюс ключ» как шаблонный код, а не как сам обходной путь
Уточняющие вопросы
- →Как интерфейс с ключом-строкой позволяет применить зарегистрированный конструктор?
- →Что даёт слияние объявлений для расширения такого реестра из пользовательского кода?