open atlas
↑ К треку
Система типов TypeScript вглубь TS · 03 · 01

Дженерик-функции

Дженерик-функция — это функция над типами. Проверяющий читает аргументы вызова и решает каждый параметр типа, но параметр, использованный лишь раз, — это `any` в костюме.

TS Middle ◷ 14 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Коллега пишет хелпер 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: number

T решается из 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. 1 Сопоставить аргумент `user` с параметром `obj: T` и решить T = { id: number; name: string }
  2. 2 Сопоставить аргумент `'name'` с параметром `key: K`, ограниченным `K extends keyof T`
  3. 3 Решить K в литеральный тип `'name'`
  4. 4 Подставить T и K в возвращаемый тип T[K]
  5. 5 Сообщить тип результата вызова как `string`
Закончи аналогию

Заполните пропуск: параметр типа заслуживает места, встречаясь в сигнатуре хотя бы _______ раза, потому что его задача — связать одну позицию с другой.

Вспомните перед уходом
  1. 01
    Объясните на примере `wrap<T>(value: T): { value: T }`, что значит «вывод аргументов типа» и в каком направлении течёт информация.
  2. 02
    Сформулируйте правило «встречается хотя бы дважды» и приведите пример нарушения для только-параметра и для только-возврата.
Итог

Дженерик-функция абстрагируется над типами так же, как обычная функция над значениями. Проверяющий выполняет вывод аргументов типа — сопоставляет каждый аргумент с параметром, упоминающим переменную типа, решает её и подставляет обратно через возвращаемый тип. Явные аргументы типа существуют для случаев, когда выводить не из чего или нужен тип шире. Решающий тест на запах — правило «встречается хотя бы дважды»: однократный параметр это any или утверждение в костюме. Дальше мы добавим ограничения extends, которые одновременно ограничивают, кто может вызвать дженерик, и открывают доступ к членам внутри него. Теперь, когда встретишь <T> в сигнатуре, первый вопрос — сколько раз он встречается и какие именно две позиции связывает.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources1
expand
  1. 01

Trademarks belong to their respective owners. Editorial reference only.