Дженерик-функции
Дженерик-функция — это функция над типами. Проверяющий читает аргументы вызова и решает каждый параметр типа, но параметр, использованный лишь раз, — это `any` в костюме.
Коллега пишет хелпер first<T>(arr: T[]): T и радуется: «Это дженерик, работает с любым массивом!» Через две недели кто-то пишет identity<T>(x: unknown): T и приводит к нему весь мир. У обоих есть <T>. Но реально работает лишь один — второй это any в костюме, и компилятор вас об этом никогда не предупредит. К концу урока вы будете знать, какой из них опасен и как это видно прямо в сигнатуре.
Функция над типами
Обычная функция абстрагируется над значениями: вы передаёте 3 или "x", и тело работает одинаково. Дженерик-функция абстрагируется над типами: вы передаёте тип (явно или, гораздо чаще, неявно), и сигнатура специализируется под него.
function identity<T>(x: T): T {
return x;
}
const a = identity("hello");
// ^? const a: "hello"
const b = identity(42);
// ^? const b: number<T> объявляет параметр типа — заглушку в пространстве типов, ровно как параметр-значение это заглушка в пространстве значений. В каждой точке вызова проверяющий выбирает для T конкретный тип, а возвращаемый тип следует за ним.
Вывод: проверяющий решает T
Вы почти никогда не пишете identity<string>("hello"). Вместо этого проверяющий выполняет вывод аргументов типа: сопоставляет тип каждого аргумента с параметром, который использует переменную типа, и решает её.
function wrap<T>(value: T): { value: T } {
return { value };
}
const w = wrap([1, 2, 3]);
// ^? const w: { value: number[] }Здесь value: T сопоставляется с аргументом [1, 2, 3] (выведенным как number[]), поэтому T = number[], а возвращаемый тип вычисляется как { value: number[] }. Вывод течёт из позиции аргумента в T, а затем обратно через позицию возврата.
Явные и выведенные аргументы типа
Вы можете задать аргумент типа вручную, а иногда и обязаны — когда выводить не из чего или когда нужен тип шире, чем выбрал бы вывод.
function make<T>(): T[] {
return [];
}
const xs = make();
// ^? const xs: unknown[] — выводить не из чего, T падает в unknown
const ys = make<string>();
// ^? const ys: string[] — явный аргумент типаКогда вы ничего не пишете и выводить не из чего, неограниченный T при современных настройках разрешается в unknown (не в any). Явные аргументы типа также полезны, чтобы сузить вывод: wrap<readonly number[]>([1, 2, 3]) навязывает readonly-тип элементов, который один вывод не выбрал бы.
Несколько параметров типа и pluck
Реальные сигнатуры часто несут несколько переменных типа, которые проверяющий решает независимо:
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
const p = pair("id", 7);
// ^? const p: [string, number]Самая полезная форма дженерика в прикладном коде — это pluck / prop: прочитать ключ объекта и получить обратно ровно тип этого свойства. (Механика K extends keyof T — это следующий урок; здесь просто видно, зачем нужны два параметра.)
function prop<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Ada" };
const n = prop(user, "name");
// ^? const n: string
const id = prop(user, "id");
// ^? const id: numberT решается из obj, K из key, а возвращаемый тип T[K] (индексный доступ — это было в прошлом уроке) возвращает точный тип свойства. Два параметра, каждый встречается дважды.
Правило «встречается хотя бы дважды»
Вот правило, отделяющее настоящие дженерики от карго-культа: параметр типа, встречающийся в сигнатуре лишь раз, почти всегда ошибка. Переменная типа оправдывает себя, когда связывает две позиции — аргумент с возвратом или два аргумента между собой. Если она встречается раз, она ничего не связывает и в этом месте молча эквивалентна any.
// ЗАПАХ: T встречается только в параметре, ни с чем не связан.
function logAndDrop<T>(x: T): void {
console.log(x);
}
// По поведению и безопасности идентично:
function logAndDrop2(x: unknown): void {
console.log(x);
}
// ХУЖЕ: T встречается только в возврате — вызывающий выдумывает T из ничего.
function parse<T>(text: string): T {
return JSON.parse(text);
}
const cfg = parse<{ port: number }>("{}");
// ^? const cfg: { port: number } — ЛОЖЬ; значение в рантайме {}В parse<T> ничто из аргументов не ограничивает T, поэтому аннотация вызывающего — непроверяемое утверждение (assertion). Такой однократный T в позиции возврата — один из самых частых источников «типы сказали, что всё хорошо, а продакшен сказал иначе».
Во что обходится компилятору чрезмерно дженериковый код
<T>, который оправдывает себя, почти бесплатен; <T>, рассыпанный повсюду, — нет. Метрика, за которой нужно следить, — инстанцирования типов (type instantiations), которые показывает tsc --extendedDiagnostics. Листовой утилитный модуль, типизирующий данные напрямую, может давать порядка десятков тысяч инстанцирований; перепишите те же хелперы так, чтобы каждая сигнатура была дженериковой и сцепленной (каждый вызов заново решает T и протаскивает его через несколько других дженериковых вызовов) — и счётчик уходит в сотни тысяч. Instantiations: 412 300 против прежних 38 900 — реалистичная разница для одного переусложнённого утилитного файла, и она напрямую превращается в лишние несколько сотен миллисекунд Check time на каждой инкрементальной пересборке. Дженерики — это цикл этапа компиляции, который проверяющий прогоняет на каждой точке вызова; дёшево за одно инстанцирование, но их можно написать очень много.
Самый опасный случай — рекурсивные дженерики. Проверяющий ограничивает, насколько глубоко он будет инстанцировать дженериковый алиас, прежде чем сдаться: достигните примерно 50 уровней вложенности — и получите Type instantiation is excessively deep and possibly infinite (ошибка TS 2589) — тот же потолок глубины, что ограничивает рекурсивные условные типы. Один fluent-билдер, типизированный как Builder<readonly [A]> → Builder<readonly [A, B]> → …, упирается прямо в эту стену, как только цепочка становится длинной, и единственные выходы — разорвать рекурсию или откатиться к недженериковой форме.
▸Почему это работает
Почему правило «лишь раз» работает? Параметр типа — это связь между позициями. identity<T>(x: T): T осмысленна, потому что обещает «то, что на выходе, того же типа, что и на входе». Удалите один из двух T — и обещание исчезает: связи, которую нужно соблюдать, больше нет, поэтому переменная не несёт информации. Прежде чем добавлять <T>, посчитайте вхождения: меньше двух — и вы написали any (или, для случая только-возврат, замаскированное утверждение).
Компромисс, который взвешивает сеньор, — гибкость против двух издержек: читаемости на точке вызова и работы по инстанцированию для проверяющего. Дженерик, связывающий две позиции, покупает реальную безопасность и стоит обеих издержек. Дженерик с однократным параметром не покупает ничего и платит обе — сигнатуру труднее читать и лишнее инстанцирование на каждый вызов. Дисциплина не в «предпочитайте дженерики»; она в «вводите параметр типа, только когда он связывает позиции, и переставайте обобщать в тот момент, когда конкретный тип читался бы яснее и проверялся так же надёжно».
Для `function wrap<T>(value: T): { value: T }` какой выведенный тип у `wrap(new Date())`?
Почему `function parse<T>(text: string): T { return JSON.parse(text) }` считается небезопасной?
Расставьте шаги, которые делает проверяющий, типизируя вызов `prop(user, 'name')` для `prop<T, K extends keyof T>(obj: T, key: K): T[K]`:
- 1 Сопоставить аргумент `user` с параметром `obj: T` и решить T = { id: number; name: string }
- 2 Сопоставить аргумент `'name'` с параметром `key: K`, ограниченным `K extends keyof T`
- 3 Решить K в литеральный тип `'name'`
- 4 Подставить T и K в возвращаемый тип T[K]
- 5 Сообщить тип результата вызова как `string`
Заполните пропуск: параметр типа заслуживает места, встречаясь в сигнатуре хотя бы _______ раза, потому что его задача — связать одну позицию с другой.
- 01Объясните на примере `wrap<T>(value: T): { value: T }`, что значит «вывод аргументов типа» и в каком направлении течёт информация.
- 02Сформулируйте правило «встречается хотя бы дважды» и приведите пример нарушения для только-параметра и для только-возврата.
Дженерик-функция абстрагируется над типами так же, как обычная функция над значениями. Проверяющий выполняет вывод аргументов типа — сопоставляет каждый аргумент с параметром, упоминающим переменную типа, решает её и подставляет обратно через возвращаемый тип. Явные аргументы типа существуют для случаев, когда выводить не из чего или нужен тип шире. Решающий тест на запах — правило «встречается хотя бы дважды»: однократный параметр это any или утверждение в костюме. Дальше мы добавим ограничения extends, которые одновременно ограничивают, кто может вызвать дженерик, и открывают доступ к членам внутри него. Теперь, когда встретишь <T> в сигнатуре, первый вопрос — сколько раз он встречается и какие именно две позиции связывает.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.