Ограничения дженериков (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 компилятору не известны.
  • Благодаря структурной типизации под ограничение подходит любой объект нужной формы, даже без явного объявления через этот интерфейс.
Проверьте себя
1. Почему функция function printLength<T>(item: T) { console.log(item.length); } не компилируется без ограничения на T?
AВ TypeScript вообще запрещено использовать поле length
BКомпилятор считает T максимально широким типом и не может гарантировать, что у произвольного T есть поле length
CЭто опечатка, правильно писать item.Length с большой буквы
DДженерики не поддерживают обращение к полям объектов
2. Что означает T extends HasLength в объявлении дженерика — в отличие от extends у классов?
AT должен быть классом-наследником HasLength
BT обязан быть структурно совместим с HasLength — иметь нужные поля, необязательно явно объявляя связь с этим интерфейсом
CЭто синоним ключевого слова implements
DT автоматически получает все методы HasLength в рантайме