Ограничения дженериков
Ограничение `T extends C` — это двусторонний контракт: оно ограничивает, какие аргументы вправе передать вызывающие, и взамен даёт телу доступ к членам C. Классическая ловушка — вернуть тип C вместо T и стереть точность.
Вы пишете function longest<T>(a: T, b: T) { return a.length > b.length ? a : b }, и редактор загорается красным: Property 'length' does not exist on type 'T'. Все хватаются за T extends { length: number }. Но мало кто спрашивает, что эта строка дала — она сделала сразу две вещи, и упустить любую из них значит тихо потерять типовую информацию дженерика.
Ошибка и контракт
Внутри тела дженерика T непрозрачен — проверяющий ничего о нём не знает, поэтому a.length отвергается. Ограничение сообщает проверяющему нижнюю границу: каждый T будет как минимум этой формы.
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length > b.length ? a : b;
}
const s = longest("alpha", "beta");
// ^? const s: string
const arr = longest([1, 2], [3, 4, 5]);
// ^? const arr: number[]
longest(10, 20);
// Error: Argument of type 'number' is not assignable to
// parameter of type '{ length: number }'.Ограничение выполнило две задачи одновременно:
- Ограничило вызывающих. Принимаются только аргументы, присваиваемые
{ length: number }—numberотвергается в точке вызова. - Открыло члены внутри. Поскольку каждый
Tтеперь гарантированно имеетlength: number, тело вправе читатьa.length.
Это и есть контракт: вызывающие отдают свободу; тело получает возможности. Упустите любую сторону — и вы либо чрезмерно ограничите API, либо не сможете использовать значение, которое ограничили.
Ограничение по ключу: keyof
Канонический ограниченный дженерик — чтение свойства по ключу. Нам нужен ключ, который один из собственных ключей объекта, а не любая строка:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Ada", admin: true };
const name = getProperty(user, "name");
// ^? const name: string
const admin = getProperty(user, "admin");
// ^? const admin: boolean
getProperty(user, "email");
// Error: Argument of type '"email"' is not assignable to
// parameter of type '"id" | "name" | "admin"'.K extends keyof T ограничивает K объединением ключей T. Проверяющий выводит T из obj, затем keyof T становится "id" | "name" | "admin", и key должен быть одним из этих литералов. Возвращаемый тип T[K] — это индексный доступ, точный тип именно того свойства. Передайте несуществующий ключ — и ошибка назовёт допустимый набор.
Ограничение по форме или объединению
Ограничением может быть любой тип, не только объектный литерал:
// По форме с id — полезно для хелперов «сущностей».
function withId<T extends { id: string }>(items: T[]): Map<string, T> {
const m = new Map<string, T>();
for (const it of items) m.set(it.id, it);
return m;
}
// По объединению — ограничить известным набором тегов.
type Tag = "info" | "warn" | "error";
function level<T extends Tag>(t: T): T {
return t;
}
const l = level("warn");
// ^? const l: "warn" — узкий литерал сохранёнЛовушка точности: вернуть C вместо T
Вот баг, который ловят на ревью. Вы ограничиваете T extends C, а потом небрежно типизируете возврат как C. Функция всё равно компилируется, но стирает точный тип вызывающего вниз до ограничения.
// БАГ: возвращает ограничение, а не переменную типа.
function identityBad<T extends { id: string }>(x: T): { id: string } {
return x;
}
const r1 = identityBad({ id: "u1", role: "admin" as const });
// ^? const r1: { id: string } — потерян `role`! сужено до ограничения
// ФИКС: вернуть T, сохранив всё, что передал вызывающий.
function identityGood<T extends { id: string }>(x: T): T {
return x;
}
const r2 = identityGood({ id: "u1", role: "admin" as const });
// ^? const r2: { id: string; role: "admin" } — полный тип сохранёнСпросите себя: в возвращаемом типе написан T или само ограничение C? Возврат ограничения выбрасывает точный тип вызывающего, и лучше бы вы взяли обычный параметр типа { id: string }. Дженерик стал декоративным.
Цена ужесточения ограничения
Ограничение — это ещё и решение про API с измеримым радиусом поражения. Ужесточите T extends { id: string } до T extends { id: string; tenantId: string } — и каждый вызывающий, кто раньше передавал голый { id }, теперь падает с ошибкой: на сервисе с парой сотен точек вызова это разница между зелёной сборкой и PR, который трогает сорок файлов. Ошибка громкая и в правильном месте (на точке вызова), и в этом весь смысл ограничения, но рефлекс сеньора — спросить, место ли этому требованию в сигнатуре вообще, или более узкий внутренний хелпер локализовал бы churn. Ослабьте ограничение наоборот (уберите обязательный член) — и происходит обратное, молча: вызывающие, опиравшиеся на этот член, продолжают компилироваться, а телу, которое его читает, теперь нужен запасной путь, который вы не написали.
Есть и цена для проверяющего. Ограничение вроде K extends keyof T делает T[K] индексным доступом (indexed access), который проверяющий разрешает на каждой точке вызова; над широким объектом — типом конфига, скажем, со 120 ключами — keyof T это union из 120 членов, и каждый вызов getProperty инстанцируется и сопоставляется с ним. Один такой хелпер — ничто; слой из них поверх больших mapped-типов — это там, где вы наблюдаете скачок счётчика Instantiations в --extendedDiagnostics, и tsc из шустрого превращается в заметную паузу на каждое нажатие клавиши в редакторе.
▸Почему это работает
Ограничение против дефолта — частая путаница: <T extends string> и <T = string> выглядят похоже, но делают противоположное. extends — верхняя граница, требование, которому аргумент должен удовлетворять, проверяемое в точке вызова. = — это запасной вариант, тип, используемый, только когда вывод ничего не нашёл, и никогда не навязываемый. Можно иметь оба: <T extends string = "id"> значит «должен быть подтипом string, а если вы не задали и не вывели, используй литерал "id"». Дефолты — следующий урок; здесь просто не читайте extends как «по умолчанию равно».
Компромисс в жёсткости ограничения: более слабое ограничение принимает больше вызывающих, но открывает внутри меньше членов, толкая тело к приведениям или лишним рантайм-проверкам; более жёсткое открывает больше, но отвергает вызывающих и расходится по всем точкам вызова при следующем ужесточении. Калибровка сеньора — ограничивать ровно теми членами, которых тело реально касается: не слабее (иначе не напишете тело), не жёстче (иначе вы связали вызывающих требованиями, которые им не нужны). Когда ограничение начинает называть члены, которые тело никогда не читает, это сигнал, что оно сползло в чрезмерную ограничительность.
В `getProperty<T, K extends keyof T>(obj: T, key: K): T[K]` что делает ограничение `K extends keyof T`?
Почему `function f<T extends { id: string }>(x: T): { id: string }` — ошибка?
Расставьте, как проверяющий типизирует `getProperty(user, 'name')`, где `user: { id: number; name: string }`:
- 1 Вывести T из `user` как { id: number; name: string }
- 2 Вычислить ограничение `keyof T` как объединение 'id' | 'name'
- 3 Проверить, что аргумент 'name' присваиваем этому объединению, затем вывести K = 'name'
- 4 Разрешить индексный доступ T[K] = T['name']
- 5 Сообщить тип результата как string
Заполните пропуск: ограничение дженерика — это _______ контракт: оно стоит вызывающим части свободы и возвращает телу гарантированные члены.
- 01Объясните два эффекта записи `T extends { length: number }` и какую ошибку вы получаете без ограничения.
- 02В чём ловушка точности с ограничениями и как избежать её в хелперах вида `getProperty`?
Ограничение T extends C — это контракт в основе полезных дженериков: оно ограничивает, какие аргументы вправе передать вызывающие (всё, что присваиваемо C), и взамен даёт телу использовать гарантированные C члены. Ограничение K extends keyof T — канонический паттерн безопасного доступа к свойству, возвращающий точный индексный тип T[K]. Повторяющийся баг на ревью — возврат ограничения вместо T, который стирает точный тип вызывающего и делает дженерик декоративным; и не путайте extends (требование) с = (запасной вариант). Дальше мы добавим дефолты и изучим, как именно они взаимодействуют с выводом, включая утилиту NoInfer для блокировки вывода в позиции. Теперь, когда встретишь дженерик-функцию на ревью, сначала смотри на тип возврата: если там написано C, а не T — типовая информация уже потеряна.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.