extends в дженериках: ограничения

дженерики≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Дженерик обещает компилятору «сюда подойдёт любой тип» — и компилятор держит это обещание буквально: внутри функции у T нет ни одного свойства. Слово extends сужает обещание до «любой тип, у которого есть хотя бы вот это», и ровно на столько же расширяет ваши права внутри.

Разделы темы:·

Первая статья главы закончилась неудобным выводом: внутри функции про T не известно почти ничего, потому что тело проверяется один раз и обязано подойти любой будущей подстановке. Прежде чем чинить это словом extends, стоит посмотреть, чего именно компилятор боится.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика одна: Свойство "length" не существует в типе "T". — знакомый по прошлым главам TS2339. Обратите внимание, куда компилятор показывает: не на вызов, а на строку внутри функции. Он спорит не с аргументом, а с самим телом — оно нарушает обещание, данное в сигнатуре.

А выполнение всё равно случилось, и оно объясняет, почему компилятор прав. Первый вызов напечатал 6: у строки length есть. Второй напечатал undefined: у числа его нет, и никто не помешал спросить. Отловить это на этапе выполнения было бы уже поздно — беззвучный undefined уехал бы дальше по коду.

TS2339 внутри дженерика всегда читается одинаково: вы хотите от T больше, чем обещали. Выхода два — либо сузить обещание, либо перестать хотеть.

Слово extends здесь читается не как «наследует», а как «подходит под». Запись T extends { length: number } означает: T — любой тип, который годится на место { length: number }. Проверка та же самая структурная, что и при обычном присваивании: важен состав, а не имя и не происхождение.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики — и три разных типа на входе. Строка, массив и объект, придуманный на месте: у каждого есть length типа number, больше ограничение ничего не требует.

Тут возникает законный вопрос: если всё равно нужен объект с length, зачем дженерик — напишите такой тип прямо в параметре. Разница видна не на входе, а на выходе.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обычный параметр стирает тип до объявленного: внутрь зашла строка, наружу вышло { length: number }, и toUpperCase уже не найти — Свойство "toUpperCase" не существует в типе "{ length: number; }". На запуске, конечно, всё печатается: в памяти лежит та же строка, потерялся только тип. Дженерик с тем же ограничением проверяет ровно то же самое, но возвращает то, что пришло: logLength("привет") отдаёт string, logLength([1, 2, 3]) — number[].

Ограничение — это фильтр на входе. Тип T после него всё равно вычисляется по аргументу, а не подменяется ограничением.

Один и тот же промах компилятор описывает разными словами — в зависимости от того, сами вы назвали T или доверили это выводу. Три строки ниже нарушают одно и то же ограничение.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три диагностики, три разных кода — и это не прихоть, а три разных места провала.

Как написаноКодЧто говорит компилятор
logLength(42)TS2345Аргумент типа "number" нельзя назначить параметру типа "{ length: number; }".
logLength<number>(42)TS2344Тип "number" не удовлетворяет ограничению "{ length: number; }".
logLength({ length: "шесть" })TS2322Тип "string" не может быть назначен для типа "number".

В первом случае вывод подставил T = number, и дальше сорвалась обычная проверка аргумента — про ограничение в тексте ни слова, хотя виновато именно оно. Во втором вы назвали тип руками, и компилятор проверил его прямо на месте: TS2344 — единственная формулировка, где слово «ограничение» звучит вслух. В третьем объект почти подошёл, и компилятор не стал говорить про весь тип целиком — показал ту самую строку с неверным полем.

Когда «почти подошёл» — это вложенный объект или массив, диагностика разворачивается в цепочку причин, как в главе про чтение диагностик: читать её нужно снизу вверх.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Четыре уровня: массив не подошёл, потому что не подошёл его элемент, потому что несовместимо свойство id, потому что string — не number. Верхняя строка называет место в вашем коде, нижняя — настоящую причину. Первый вызов при этом прошёл молча: у объектов есть лишнее поле name, но ограничение требует только id.

Не нашли слова «ограничение» в тексте ошибки — посмотрите на объявление функции. TS2345 про параметр странной формы почти всегда означает промах мимо extends.

Справа от extends стоит обычный тип — любой, какой вы умеете написать. Объект с полями это будет или примитив, компилятор проверяет одинаково: «годится ли аргумент на место того, что справа». Вот та самая функция shout из первой статьи главы, которой не хватало ровно одного слова.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вот тот случай, ради которого стоит запускать примеры с ошибками. Компилятор сказал Аргумент типа "number" нельзя назначить параметру типа "string". и на этом свою работу закончил — файл всё равно превратился в JavaScript, как и делает tsc по умолчанию. А дальше уже движок: TypeError: text.toUpperCase is not a function. Ограничение не защищает во время работы, оно предупреждает до неё.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

С объединением компилятор перечисляет допустимое прямо в тексте: Аргумент типа ""овал"" нельзя назначить параметру типа ""круг" | "квадрат"". С классом — называет недостающее: Свойство "name" отсутствует в типе "{ message: string; }" и является обязательным в типе "Error". Никакого instanceof при этом не происходит: extends Error — всё та же проверка состава, и самодельный объект с name, message и stack её пройдёт.

ОграничениеЧто открывает внутриЧто не пройдёт
T extends { length: number }value.length42, true
T extends stringвсе методы строк42, массив
T extends "круг" | "квадрат"сравнение с этими двумя литераламилюбая другая строка
T extends Errorname, message, stackобъект без name
T extends unknown[]методы массивастрока — length есть, а массивом не является
T extends objectничего конкретного, только отсечение примитивов"привет", 7, null

Один тип-параметр можно ограничить через другой — и самый частый случай выглядит так: K обязан быть ключом того объекта, который пришёл в T. Опечатку в имени поля компилятор ловит сразу, а тип результата остаётся точным.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ошибка на последней строке — Аргумент типа ""email"" нельзя назначить параметру типа ""name" | "age"".: keyof T развернулся в объединение имён полей. Что такое keyof и почему T[K] даёт именно string, разбирается в главе про типы, вычисляемые из значений, — здесь важно только, что справа от extends может стоять выражение, зависящее от соседнего параметра.

Последняя ловушка — и самая обидная, потому что код выглядит совершенно логичным. Ограничение говорит: T — это Named или что-то поточнее. Значит, вернуть готовый Named можно? Попробуйте предсказать, что скажет компилятор.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор объясняет отказ необычно подробно: "{ name: string; }" может быть назначен ограничению типа "T", но можно создать экземпляр "T" с другим подтипом ограничения "Named". Перевести это можно так: да, ваш объект удовлетворяет ограничению — но обещали вы не ограничение, а T, а T вызывающий выбирает сам.

Запуск подтверждает опасение: back.age напечатал undefined. Вызов подставил T = { name: string; age: number }, снаружи по типам обещано поле age, а функция вернула объект без него. Ограничение задаёт потолок знаний о T — минимум, на который можно рассчитывать. Оно ничего не говорит о том, чего в T ещё окажется.

Отсюда практическое правило: внутри функции значение типа T можно читать, но нельзя собрать. Единственный законный источник T — то, что пришло снаружи; его разрешено дополнить.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики: компилятор видит, что в результат попали все поля item, а перезаписанное name ограничению не противоречит. Тот же приём, что и с копированием объектов в первой части, — только теперь у него появилась вторая причина.

extends у класса и extends в дженерике — разные слова-омонимы. Первое строит наследование, второе ничего не создаёт и лишь проверяет совместимость.

  • Без ограничения внутри функции у T нет ни одного свойства: тело проверяется заранее, под обещание «подойдёт любой тип».
  • T extends X — не наследование, а структурная проверка «годится ли аргумент на место X»; справа может стоять любой тип, включая примитив, объединение и класс.
  • Ограничение фильтрует вход, но не подменяет T: наружу возвращается ровно тот тип, который пришёл, — этим дженерик и отличается от обычного параметра.
  • Явный аргумент типа даёт TS2344 со словом «ограничение», выведенный — TS2345 про аргумент, а почти подходящий объект — вложенную цепочку причин.
  • Ограничение задаёт минимум, а не точный тип: собрать значение T внутри функции нельзя, можно только дополнить пришедшее снаружи.
  • Диагностику компилятор выдаёт, но выполнение не отменяет — проигнорированное предупреждение возвращается настоящим TypeError.

1/4Ограничение записано как T extends { length: number }. Пройдёт ли туда строка, которая ничего не наследует от объекта с полем length?

2/4Функция объявлена как logLength<T extends { length: number }>(value: T): T. Какой тип у результата вызова logLength("привет")?

3/4Ограничение T extends { length: number }. Вызовы logLength(42) и logLength<number>(42) дают разные коды ошибок. Какой из них называет ограничение вслух?

4/4В функции <T extends { name: string }>(item: T): T строка return { name: "новое имя" }; не проходит проверку. Почему?

Добавить ограничение

Функция считает сумму по полю price, и на реальных данных она работает — но компилятор выдаёт TS2339. Допишите ограничение так, чтобы диагностика исчезла, а функция по-прежнему принимала любые объекты, у которых есть цена.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Функции нужно ровно одно знание о T — что у него есть числовое поле price. Его и записываем справа от extends.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Поле title ограничению не мешает: оно требует минимум, а не точное совпадение. Соблазн написать items: { price: number }[] вместо дженерика здесь тоже сработает — функция возвращает число, и точный тип элементов наружу не идёт. Дженерик станет обязательным, как только вы захотите вернуть сам элемент.

Прочитать цепочку причин

Тут диагностика в четыре уровня. Прочитайте её снизу вверх, найдите настоящую причину и почините вызов, не трогая объявление findById.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Нижняя строка цепочки говорит всё: Тип "string" не может быть назначен для типа "number". Выше сказано, у какого свойства проблема — id, ещё выше — что элемент не подошёл под ограничение, и только верхняя строка показывает место в коде. Идентификаторы записаны строками, а ограничение требует число.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Заодно видно, зачем здесь дженерик: результат — не { id: number }, а исходный элемент со всеми полями, поэтому title доступен. И обратите внимание на исходный запуск: без исправления функция молча вернула undefined — "2" === 2 ложно.

Ограничение под задачу

Ограничение написано «пошире, чтобы всё пролезло»: object отсекает только примитивы. Компилятор выдаёт две диагностики, и обе по делу. Опишите ограничением ровно то, чем пользуется тело функции.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тело читает два поля — их и перечисляем. Лишнее поле age вызову не помешает: ограничение требует минимум, а не точное совпадение.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь вызов initials({ firstName: "Аня" }) отсекается на месте: Свойство "lastName" отсутствует в типе "{ firstName: string; }" и является обязательным.... С прежним extends object он бы прошёл проверку и упал уже при выполнении — Cannot read properties of undefined. Правило: слишком широкое ограничение закрывает нужные свойства внутри и пропускает мусор снаружи, слишком узкое — отсекает подходящие вызовы.