Ограничения дженериков (extends)
«Напиши функцию, которая берёт объект и печатает длину его поля length» — и кандидат сходу пишет дженерик с T. Компилятор тут же ругается на то, что у типа T «нет свойства length» — хотя, казалось бы, T может быть чем угодно. Разбираемся, почему это правильное поведение и как его исправить через extends.
Ограничение дженерика (constraint), записываемое как <code>T extends Something</code>, сужает набор допустимых типов для параметра T: теперь T — это уже не «буквально любой тип», а «любой тип, у которого точно есть то, что описано в Something».
Вопрос-крючок
Вот та самая функция без ограничения — и ошибка компиляции, которую она вызывает:
function printLength<T>(item: T): void {
console.log(item.length);
// Ошибка компиляции:
// Property 'length' does not exist on type 'T'.
}Код даже не соберётся — TypeScript откажется его пропускать. Логика компилятора железная: он обещал, что T подходит для абсолютно любого типа, который туда передадут — хоть строку, хоть число, хоть булево значение. А раз T может оказаться числом, то <code>item.length</code> — обращение к полю, которого у числа попросту нет. Компилятор не станет гадать «а вдруг обычно сюда передают строки» — без явного ограничения он обязан считать T максимально широким.
Как сузить T через extends
Решение — явно сказать компилятору, каким минимальным набором свойств должен обладать T, чтобы функция вообще имела смысл. Для этого T снабжают ограничением через ключевое слово <code>extends</code>:
interface HasLength {
length: number;
}
function printLength<T extends HasLength>(item: T): void {
console.log(item.length);
}
printLength("привет"); // строка — у неё есть length
printLength([1, 2, 3, 4]); // массив — тоже есть length
printLength({ length: 10, unit: "cm" }); // и обычный объект с полем length подойдёт
// printLength(42); // ошибка: у number нет свойства lengthВывод:
6
4
10Запись <code>T extends HasLength</code> читается как: «T может быть каким угодно типом, но только если у него точно есть числовое поле length». Строка подходит (у строк есть <code>.length</code>), массив подходит по той же причине, а обычный объект с полем <code>length</code> — тоже, потому что TypeScript проверяет форму (структуру), а не конкретное происхождение типа. А вот число не подходит — и компилятор ловит это ещё до запуска, а не в рантайме через <code>undefined</code>.
Важно: extends здесь — не то же самое, что у классов
Слово <code>extends</code> в контексте ограничения дженерика — это не наследование класса от класса. Здесь оно означает «удовлетворяет форме» или «совместим по структуре с». T не обязан буквально наследоваться от HasLength как от класса — достаточно, чтобы объект просто содержал нужные поля. TypeScript использует <strong>структурную типизацию</strong>: если что-то выглядит как HasLength (имеет поле length нужного типа), оно и считается подходящим под ограничение, вне зависимости от того, как это описано.
Типовая ошибка без ограничения: попытка обратиться к полю «на всякий случай»
Частый паттерн, который ломается без extends, — обобщённая функция сравнения или сортировки по какому-то полю:
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], targetId: number): T | undefined {
return items.find(item => item.id === targetId);
}
const users = [
{ id: 1, name: "Аня" },
{ id: 2, name: "Боря" },
];
const found = findById(users, 2);
console.log(found?.name);Вывод:
БоряБез <code>T extends HasId</code> строка <code>item.id === targetId</code> вообще не скомпилировалась бы — компилятор не мог бы гарантировать, что у произвольного T есть поле <code>id</code>. Ограничение даёт компилятору ровно ту информацию, которая нужна функции, и ни байтом больше — T всё ещё остаётся дженериком, просто с гарантированным минимумом возможностей.
Ограничение одним конкретным типом или union
Ограничивать можно не только собственными интерфейсами, но и встроенными типами или их объединением — это тоже частый вопрос на собеседовании:
function double<T extends number | string>(value: T): string {
if (typeof value === "number") {
return String(value * 2);
}
return value + value;
}
console.log(double(5));
console.log(double("ha"));Вывод:
10
hahaЗдесь ограничение <code>T extends number | string</code> запрещает вызывать функцию с чем-то вроде массива или булева значения ещё на этапе компиляции — и при этом внутри функции всё ещё нужна проверка <code>typeof</code>, потому что T — это по-прежнему один из двух вариантов, а не гарантированно один конкретный тип.
Как это работает под капотом
Ограничение — это условие, которое компилятор проверяет в момент вывода или подстановки T: любой кандидат на T обязан быть совместим по структуре с типом после <code>extends</code>, иначе компиляция падает с ошибкой ещё до сборки. Внутри тела функции компилятор в ответ разрешает использовать ровно то, что гарантировано ограничением — не больше. Например, при <code>T extends HasLength</code> внутри функции доступно поле <code>.length</code>, но не любые другие поля — компилятор не знает, что ещё есть у конкретного T, кроме гарантированного минимума.
| Запись | Что значит |
<T> | T — абсолютно любой тип, без гарантий о его свойствах |
<T extends HasLength> | T — любой тип, но обязательно с полем length |
<T extends number | string> | T — либо число, либо строка, ничего другого |
Частые ошибки на собеседовании
- Путают extends у дженерика с extends у класса. В ограничении дженерика это не наследование, а требование «структурно совместим с» — благодаря структурной типизации TypeScript подходящим объектом может быть вообще любой объект нужной формы, даже без явной связи с интерфейсом-ограничением.
- Не понимают, почему компилятор ругается на T без ограничения. Забывают, что без extends компилятор обязан считать T максимально широким типом — и любое обращение к специфичному полю или методу небезопасно, ведь T теоретически может оказаться чем угодно, вплоть до примитива.
- Думают, что ограничение сужает T до одного конкретного типа. На самом деле T всё ещё дженерик — просто с гарантированным минимумом возможностей. Внутри функции по-прежнему может понадобиться дополнительная проверка (как typeof в примере с number | string), если ограничение — это union из нескольких вариантов.
- Забывают, что доступны только поля из ограничения, а не всё, что «обычно» бывает у похожих объектов. Если T extends HasId, обратиться внутри функции к полю name нельзя — компилятор о нём ничего не знает, даже если в реальных вызовах передают объекты с этим полем.
Итоги
- Без ограничения дженерик T компилятор считает максимально широким типом — обращаться к специфичным полям и методам нельзя, это защита от ошибок.
- Ограничение записывается как <code>T extends Что-то</code> и означает «T обязан быть структурно совместим с этим типом», а не наследование в духе классов.
- Ограничением может быть свой интерфейс, встроенный тип или union из нескольких типов — компилятор проверит совместимость на этапе компиляции.
- Внутри функции с ограничением доступно ровно то, что гарантировано ограничением, и не больше — остальные поля потенциального T компилятору не известны.
- Благодаря структурной типизации под ограничение подходит любой объект нужной формы, даже без явного объявления через этот интерфейс.