Глава 18. Ограничения и вывод параметров
Глава 18. Ограничения и вывод параметров
T, к которому не подступиться
В прошлой главе мы выяснили, что дженерик-функция — это функция над типами: она принимает тип T и обязана работать при любом его значении. Из этого выросло железное правило: внутри тела с неограниченным T ничего нельзя предположить про его форму. Попробуем нарушить правило и посмотреть, чем ответит компилятор.
function first<T>(x: T) {
return x.id; // Ошибка: свойство 'id' не существует в типе 'T'
}
Замысел очевиден: достать у аргумента поле id. Компилятор отказывает, и отказ справедлив. T означает «любой тип, который подставит вызывающий». Подставят число — у числа нет id. Подставят null — у null нет ничего. Раз функция обещает работать при всяком T, она не вправе трогать поле, которого у T может не быть. Неограниченный T непрозрачен: его можно передать дальше, положить в массив, вернуть — но не разглядеть.
Рефлекс приходит сам, особенно если за плечами статически типизированный язык: раз нельзя тронуть .id, надо пообещать компилятору, что id там будет. Синтаксис подсказывает слово:
function first<T extends { id: string }>(x: T) {
return x.id; // тип: string
}
Заработало. Поле .id стало доступным, его тип — string. Мы что-то сообщили компилятору про T, и он разблокировал доступ. Слово extends тут знакомое до боли тем, кто писал <T extends Comparable> в Java, — и интуиция «ограничить тип сверху» верна по духу. Но extends здесь значит не то, что значит extends у класса, и от этой разницы расходится половина части IV. К ней мы вернёмся через абзац.
Сначала покажем, ради чего всё затевается, — пример, который в номинативном мире выразить нечем. Безопасный доступ к полю по ключу, где тип результата зависит от того, какой ключ передали:
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const order = { id: "A-1", total: 500, paid: true };
const id = get(order, "id"); // тип: string
const total = get(order, "total"); // тип: number
const paid = get(order, "paid"); // тип: boolean
get(order, "discount");
// Ошибка: аргумент 'discount' несовместим с параметром типа
// '"id" | "total" | "paid"'
Одна функция, и результат у неё разный для каждого ключа: string для "id", number для "total", boolean для "paid". Несуществующий ключ отвергается на этапе проверки, до всякого запуска. Это не перегрузки, выписанные руками, — это одно объявление, в котором тип возврата вычисляется из аргументов. Java такого не выражает: дженерик-метод там не умеет сказать «верни тип того поля, чьё имя пришло вторым аргументом».
Вопрос главы — как устроены эти три слова, extends, keyof и T[K], на которых держится переход от «дженерики существуют» к «дженериками можно вычислять».
extends — это граница, а не наследование
Начнём с extends в позиции ограничения. Запись <T extends Shape> читается одним предложением: «T должен быть присваиваем типу Shape». Не «T наследует Shape», не «T — подкласс Shape». Присваиваем — то есть значение типа T можно положить туда, где ждут Shape.
Вспомним множественную модель из главы 6: тип — это множество значений, а A extends B означает, что множество A целиком лежит внутри множества B. Граница Shape очерчивает область; T обязан уместиться внутри неё.
type Shape = { id: string };
function describe<T extends Shape>(x: T) {
return x.id; // разблокировано: всё, что внутри границы, имеет id
}
describe({ id: "A-1" }); // ок
describe({ id: "A-1", total: 500 }); // ок: это тоже подмножество Shape
describe({ total: 500 });
// Ошибка: объектный литерал может задавать только известные свойства,
// и 'total' нет в типе 'Shape'
Второй вызов проходит, и это сразу обнажает отличие от наследования. Объект { id: "A-1", total: 500 } нигде не объявлял, что он Shape, не наследовал его, не реализовывал. Он просто по форме помещается внутрь Shape: всё, что требует Shape, у него есть. Граница работает структурно (глава 2) — по форме значения, а не по родословной. Никакого объявленного родства не требуется и не проверяется.
Отсюда — рабочее определение. Граница T extends Shape — это минимум, который функция обещает про T. Не «T равен Shape», а «T — что угодно, но не меньше Shape». Этот минимум и есть то, что разблокирует доступ: внутри тела компилятор знает про T ровно столько, сколько записано в границе, — и ни на йоту больше. Объявили границу { id: string } — доступен .id. Поле .total, которое было у конкретного аргумента, внутри функции невидимо: его нет в границе, а функция обязана работать при любом T, в том числе при таком, где total отсутствует.
То же слово extends, что у класса, означает здесь иную операцию: не «возьми всё от родителя и добавь своё», а «впишись в эту границу». Запомнить разницу проще всего так: extends у класса смотрит вверх по иерархии наследования, extends в ограничении смотрит на присваиваемость множеств.
T может быть точнее своей границы
Граница ставит потолок: T не больше Shape. Но потолок — это не равенство. T свободно бывает уже границы, и в этой свободе — вся выгода дженерика по сравнению с обычным параметром.
Сравним два объявления:
function tagA(method: string) {
return method;
}
function tagB<T extends string>(method: T) {
return method;
}
const a = tagA("GET"); // тип: string
const b = tagB("GET"); // тип: "GET"
Оба принимают строку. Но tagA объявил параметр как string — и вернул string, расплавив литерал "GET" в общий тип всех строк. tagB объявил параметр через <T extends string> — и T вывелся как "GET", конкретный литеральный тип (литеральные типы из главы 4). Граница extends string поставила потолок «не выходи за пределы строк», но не заставила T стать именно string. Компилятор взял настолько узкий тип, насколько узок был аргумент.
Это и есть ответ на вопрос, зачем писать <T extends string>(x: T), когда есть простой (x: string). Первое сохраняет точность — удерживает, какую именно строку передали, — и пробрасывает её в тип результата. Второе точность теряет. Разница не косметическая: на ней стоит вся типобезопасность функций, чей результат зависит от конкретного входа.
type Routes = {
"/orders": Order;
"/users": User;
};
function fetchRoute<T extends keyof Routes>(path: T): Routes[T] {
return load(path) as Routes[T];
}
const order = fetchRoute("/orders"); // тип: Order
const user = fetchRoute("/users"); // тип: User
Граница extends keyof Routes держит потолок — путь обязан быть одним из ключей Routes. Но T внутри остаётся точным литералом "/orders" или "/users", и потому Routes[T] отдаёт Order для одного пути и User для другого. Ограничь параметр просто до string — и точность исчезнет, результат схлопнется в Order | User, и вызывающий потеряет конкретику. Ограничение ставит границу, но не стирает то, что внутри границы.
keyof и T[K] — рабочая пара ограничений
Мы дважды походя использовали keyof и T[K]. Пора рассмотреть их в упор — это хлеб ограничений, без них дальше не пройти.
Оператор keyof берёт тип объекта и возвращает union (объединение) его ключей — тип-сумму из всех имён полей:
type Order = {
id: string;
total: number;
status: "created" | "paid" | "shipped";
};
type OrderKey = keyof Order; // "id" | "total" | "status"
keyof Order — это не значение и не строка, это тип: объединение трёх строковых литералов. Любой из них — допустимый ключ Order, и ничто другое таковым не является.
Парный к нему — индексный доступ T[K] (indexed access type), который по типу объекта и типу ключа отдаёт тип соответствующего поля:
type TotalType = Order["total"]; // number
type Either = Order["id" | "status"]; // string | "created" | "paid" | "shipped"
Order["total"] достаёт тип поля total, минуя всякое значение, — работа целиком в типовом слое. Передашь объединение ключей — получишь объединение их типов.
Теперь соберём из этой пары канонический безопасный доступ и разберём его построчно:
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
T — тип объекта, ничем не ограничен. K extends keyof T — ключ, и его граница зависит от другого параметра: K обязан быть одним из ключей того самого T, что пришёл первым аргументом. Это и закрывает дыру — передать ключ, которого в объекте нет, нельзя, граница не пустит. Возврат T[K] — тип ровно того поля, чьё имя указано в K.
const order: Order = {
id: "A-1",
total: 500,
status: "paid",
};
const s = get(order, "status"); // тип: "created" | "paid" | "shipped"
get(order, "discount");
// Ошибка: аргумент типа '"discount"' несовместим с параметром
// типа 'keyof Order'
Результат get(order, "status") — не string вообще, а точный union статусов. Точность из предыдущей секции работает в полную силу: K вывелся как литерал "status", и T[K] отдал тип именно этого поля. Связка keyof/T[K] — то, чего номинативная система типов на уровне дженериков не выражает, и она же — заготовка для главы 20, где из неё вырастут сопоставленные типы.
Механика вывода и const-параметры
До сих пор мы писали границы, но почти не указывали T явно — компилятор выводил его сам. Разберём, как именно, потому что от механики вывода зависит, удержите вы точность или потеряете.
Компилятор выводит T из позиций аргументов. Видит вызов, смотрит, чем заполнены параметры, и подбирает T так, чтобы всё сошлось:
function wrap<T>(value: T): { value: T } {
return { value };
}
const w = wrap(500); // T выведен как number, тип: { value: number }
Когда T встречается в нескольких параметрах, кандидатов несколько, и компилятор подбирает общий тип:
function pair<T>(a: T, b: T): T[] {
return [a, b];
}
pair(1, 2); // T: number
pair({ id: "A" }, { total: 1 }); // T: { id: string } | { total: number }
pair(1, "two");
// Ошибка: аргумент типа 'string' несовместим с параметром типа 'number'
Объектные литералы компилятор сводит к их объединению — берёт общий надтип кандидатов. С несовместимыми примитивами он этого не делает: фиксирует T по первому аргументу (number) и отвергает второй. Где кандидаты не складываются в один тип, вывод не сочиняет union, а сообщает о конфликте.
Если вывести T неоткуда — он встречается только в типе возврата, — компилятор не угадывает, а откатывается к границе или к unknown. Здесь пригождаются значения по умолчанию: <T = ...> задаёт T, когда вывести его не из чего и явно никто не указал.
function make<T = string>(): T[] {
return [];
}
const xs = make(); // T по умолчанию: string, тип: string[]
const ys = make<number>(); // тип: number[]
Теперь — главная ловушка вывода, ради которой эта секция и нужна. По умолчанию компилятор выводит T широко: литералы расширяются до своих базовых типов, точно как let расширяет const (глава 4).
function route<T>(config: T): T {
return config;
}
const r = route({ method: "GET", retries: 3 });
// тип: { method: string; retries: number } — литералы расширены
"GET" стало string, 3 — number. Для функции, которой эти литералы не нужны, расширение безвредно. Но если мы строили route ради того, чтобы удержать "GET" как литерал, вывод только что выбросил ровно то, что мы хотели сохранить. Раньше единственным лекарством было заставить вызывающего писать as const на каждом аргументе — переложить заботу на чужие плечи. С const-параметром забота возвращается в объявление:
function route<const T>(config: T): T {
return config;
}
const r = route({ method: "GET", retries: 3 });
// тип: { readonly method: "GET"; readonly retries: 3 }
Модификатор const перед параметром типа говорит: выводи T так, будто аргумент написан с as const, — не расширяй литералы, добавь readonly. Точность сохраняется на стороне функции, а вызывающий пишет обычный объектный литерал, ничего не зная про as const. Граница и const сочетаются свободно: <const T extends Shape> ставит и потолок формы, и запрет на расширение.
На этом фундамент собран. Граница задаёт, чем обязан быть вход; вывод определяет, насколько точно T снимется с аргумента; const-параметр удерживает эту точность. И всё это держится на одном отношении — extends, присваиваемости множеств. То самое отношение — несущая конструкция следующей главы, где extends встанет в позицию проверки внутри условного типа и превратится в if на уровне типов.
Глазами джависта
Ограниченные параметры — самая переносимая часть этой главы.
<T extends Comparable<T>>в Java и<T extends Shape>в TS пишутся почти одинаково и означают почти одно: «Tне любой, а в пределах границы, и внутри метода можно пользоваться тем, что граница гарантирует». Рефлекс здесь не предаёт — он просто не дотягивается до новых возможностей.Первое расхождение — в природе границы. В Java
T extends Comparable<T>номинативно:Tобязан объявленно реализовать интерфейс, иначе компилятор откажет, даже если у класса есть подходящийcompareTo. В TS граница структурна —T extends { id: string }пропускает любой объект нужной формы, родства никто не спрашивает и спросить не может (глава 2).Второе — Java не удерживает литеральную точность. У неё нет типа
"GET", отдельного отString; ограниченный параметр не может вывестись в значение уже своей границы, потому что такого «уже» в системе типов просто не существует. Поэтому в Java нет и смысла, в котором<T extends String>точнее, чемString.Третье —
keyofиT[K]аналога не имеют вовсе. Сказать «тип поля, чьё имя пришло параметром» в Java нечем: ключи объекта не являются типами, а тип результата не вычисляется из аргументов. Типобезопасныйget(obj, key)из начала главы в номинативной системе невыразим — обычно его заменяют рефлексией, теряя всякую проверку на этапе компиляции.И отдельная ловушка переноса — wildcards. Джавист, привыкший писать
List<? extends Shape>иList<? super Shape>, ищет здесь тот же инструмент и не находит. В TS вариантность выводится из структуры (какTиспользуется — на входе или на выходе), а не объявляется на месте применения через?. Рефлекс wildcard сюда не переносится; место, куда он тянулся, занято другим механизмом.
Где границы кусаются
Теперь честно про то, где конструкция царапается.
Главный источник путаницы — extends перегружен. В TypeScript это слово работает в трёх разных ролях. Наследование класса: class Paid extends Order. Ограничение параметра: <T extends Shape>. И проверка внутри условного типа: T extends U ? X : Y (глава 19). Три операции, одно слово. Читать его приходится по позиции, и пока позиции не различаешь автоматически, текст типов двоится.
Дальше — калибровка границы, и ошибиться можно в обе стороны. Слишком слабая граница оставляет T непрозрачным:
function totalOf<T extends object>(x: T) {
return x.total; // Ошибка: свойство 'total' не существует в типе 'T'
}
Граница object пропускает что угодно объектное, но .total ею не гарантирован — доступ закрыт. Слишком жёсткая граница отвергает валидные аргументы:
function totalOf<T extends Order>(x: T) {
return x.total; // работает, но...
}
totalOf({ total: 500 });
// Ошибка: в типе '{ total: number; }' отсутствуют свойства 'id', 'status'
Граница Order потребовала весь заказ целиком, хотя функции хватило бы { total: number }. Калибровать границу нужно ровно по тому, что функция трогает, — ни шире, ни уже.
Вывод тоже подводит в предсказуемых местах. Без const он расширяет литералы — мы это видели, но напомнить стоит, потому что молчаливая потеря точности коварнее явной ошибки. T не выводится из позиций, где его неоткуда взять, — только из возврата, — и откатывается к границе. Когда кандидатов несколько и они несовместимы, вывод либо берёт общий надтип, либо отказывает:
function pick<T>(cond: boolean, a: T, b: T): T {
return cond ? a : b;
}
const r = pick(true, { id: "A" }, { total: 1 });
// T: { id: string } | { total: number } — общий тип, не пересечение
Отдельный угол — тонкости keyof. На объекте с индексной сигнатурой keyof отдаёт не литералы, а string (или number), и точность ключа теряется. keyof any — это string | number | symbol. А когда K оказывается объединением ключей, T[K] распределяется по нему и отдаёт объединение типов, что иногда сюрприз:
type Loose = { [k: string]: number };
type LooseKeys = keyof Loose; // string | number, не литералы
function getLoose<K extends keyof Loose>(o: Loose, k: K) {
return o[k]; // тип: number, но K — это string, литерал не удержать
}
И последнее, самое тихое. Передограничение привязывает функцию к форме, которая ей не нужна, — и тем сужает круг вызывающих без всякой выгоды. Функция, объявленная как <T extends Order>, не примет частичную форму, даже если внутри читает одно поле. Это прямой конфликт с принципом главы 16: программируй на самую узкую форму, какой хватает. Лишняя строгость границы — не безопасность, а ненужная связанность.
Ограничивай ровно настолько, чтобы разблокировать работу, и дай T остаться точным
Вот сдвиг, ради которого глава написана.
Граница — это не «насколько конкретным я хочу видеть T». Это минимальная форма, которую требует тело функции, чтобы сделать свою работу. Читаешь .id — граница { id: string }, и ни байтом больше. Индексируешь по ключу — граница K extends keyof T. Граница пишется не от желаемой конкретики, а от того, что функция трогает; всё, чего она не трогает, в границе быть не должно. Это тот же принцип, что в главе 16: программируй на самую узкую форму, какой достаточно, — только теперь он живёт на уровне типов, в виде ограничения.
И зеркальная половина того же сдвига: ограничивай ради доступа, но не ради сужения T. Граница ставит потолок, чтобы разблокировать работу с входом, — и на этом её роль кончается. Пусть T остаётся настолько точным, насколько точен тип у вызывающего: если пришёл литерал "GET", удержи "GET", не плавь его в string. Граница и точность не противоречат — граница говорит, чем T обязан быть как минимум, а вывод говорит, чем T оказался на самом деле, и второе бывает уже первого. Хороший дженерик ограничивает по нижней планке и сохраняет по верхней.
Из этого вытекает привычка тянуться к keyof и T[K] всякий раз, когда функция работает с полями по имени. Безопасность ключей и вычисление типа результата из аргумента — то, чего иначе не получить; едва увидел obj[key], спрашивай себя, не должна ли граница быть K extends keyof T, а возврат — T[K].
И последнее, что должно перещёлкнуть. Читай extends в ограничении не как наследование, а как «присваиваем, подмножество». Под этим углом ограничения перестают быть забором вокруг типа и становятся тем, чем они и являются на самом деле, — воротами к вычислению. То самое отношение extends, что здесь задаёт границу, в следующей главе встанет в позицию вопроса — T extends U ? ... : ... — и превратит систему типов из описательной в вычисляющую.
Итог
Ограничение — это контракт, который функция над типами требует от своего входа: минимальная форма, разблокирующая работу с ним, и ничего сверх неё. Кто ограничивает ровно до нужной формы и оставляет T настолько точным, насколько точен тип у вызывающего, тот получает вычисление, не теряя ни безопасности, ни конкретики.