Глава 17. Дженерики как функции над типами
Глава 17. Дженерики как функции над типами
А что, если дженерик — это функция
Спросите программиста, пришедшего из Java, что такое дженерик, и ответ будет почти рефлекторным: коробка для типобезопасных коллекций. List<T>, Map<K, V> — пишешь параметр в угловых скобках, и компилятор следит, чтобы из списка строк не вылезло число. При необходимости ставишь границу: <T extends Comparable<T>>. Вот, в общем, и всё ремесло. Дженерик параметризует контейнер и иногда ограничивает параметр. Полезно, привычно, скучно.
С этой интуицией можно прожить в TypeScript годы и ни разу не заметить, что держишь в руках. Дженерики TS внешне те же — угловые скобки, параметр, граница, — и поначалу ведут себя как джавовские. А потом натыкаешься вот на такое:
type ElementType<T> = T extends (infer U)[] ? U : T;
type A = ElementType<string[]>; // string
type B = ElementType<number>; // number
Прочитайте, что здесь происходит. ElementType берёт тип T, заглядывает в его структуру — массив это или нет, — и в зависимости от ответа возвращает либо тип элемента, либо сам T. Это не коробка. Это не контейнер. Это нечто, что принимает тип на вход, что-то с ним вычисляет и выдаёт тип на выход. Если у вас в голове дженерик — это List<T>, объяснить такую конструкцию нечем: коробка не умеет ветвиться по структуре своего содержимого и возвращать разное.
Синтаксис infer и условный ? : мы разберём через две главы; сейчас важен не он, а сам факт, что строчка возможна. Она требует другой исходной картины. Что если угловые скобки — это не «параметр контейнера», а список аргументов? Что если ElementType<T> — это вызов, где T — переданный аргумент, а тело справа от = — вычисление над ним? Что если дженерик — это функция, у которой и аргументы, и результат суть типы?
Примите эту переформулировку всерьёз — и вся четвёртая часть книги, всё, что обычно подают как «продвинутую магию типов», окажется обыкновенным применением функций. Откажитесь от неё — и каждый условный, сопоставленный, рекурсивный тип будет выглядеть заклинанием, которое работает по причинам, недоступным пониманию.
Дженерик — это функция над типами
Начнём с самого скромного дженерика, какой можно написать, и прочитаем его буквально.
type Box<T> = { value: T };
type StringBox = Box<string>; // { value: string }
type NumberBox = Box<number>; // { value: number }
Box — не тип. Сам по себе он типом не является: написать const b: Box = ... нельзя, компилятор потребует аргумент. Box — это нечто, из чего получают тип, подставив T. Он принимает один параметр и возвращает структуру, в которую этот параметр вставлен. Box<string> — это применение: взяли Box, передали string, получили { value: string }.
Сопоставьте это с обычной функцией над значениями — той, что из главы 13, где функции были полноправными гражданами:
const box = (value: number) => ({ value });
const b = box(42); // { value: 42 }
Структура одна и та же, отличается только этаж. box берёт значение и возвращает значение. Box берёт тип и возвращает тип. У box параметр value живёт в круглых скобках; у Box параметр T живёт в угловых. box(42) подставляет 42 вместо value; Box<string> подставляет string вместо T. Это два экземпляра одного понятия — функция, применение, подстановка аргумента, — разнесённые по двум слоям языка.
Эти два слоя стоит назвать прямо, потому что вся книга держится на их различении:
- функции над значениями живут в исполняющей программе, берут значения и возвращают значения, работают в рантайме;
- функции над типами живут в проверяющей программе, берут типы и возвращают типы, работают во время проверки и стираются до запуска.
Box<T> — функция второго рода. Её «вызов» — Box<string> — происходит в голове у type checker’а, когда он сводит формы; в скомпилированном .js от него не остаётся ничего, как не остаётся ни от какого типа. Но внутри проверяющей программы это полноценное применение функции, с аргументом и результатом.
Тело такой функции не обязано быть простой подстановкой. Оно может комбинировать параметр с другими типами:
type Pair<A, B> = { first: A; second: B };
type Point = Pair<number, number>; // { first: number; second: number }
type Labeled = Pair<string, Order>; // { first: string; second: Order }
Два параметра — две позиции в списке аргументов, ровно как у функции двух переменных. Порядок значим: Pair<string, Order> и Pair<Order, string> — разные результаты, потому что A и B подставляются в разные места тела. Никакой новой сущности здесь не появилось — только функция от двух аргументов вместо одного.
Параметр типа — это связанное имя
У функции над значениями параметр — это имя, которое внутри тела обозначает то, что передадут снаружи. У функции над типами всё устроено так же: T в Box<T> — связанное имя, областью видимости которого служит тело определения.
type Wrap<T> = { value: T; tag: string };
// ↑ объявление имени T
// ↑ использование T внутри тела
T существует только между угловыми скобками и концом определения. Снаружи имени T нет — есть конкретные аргументы, которые в него подставят. Это в точности область видимости параметра функции: value в (value) => ... виден внутри стрелки и нигде больше.
Раз T — связанное имя, у него, как у параметра функции, может быть значение по умолчанию. Передадут аргумент — возьмётся он; не передадут — возьмётся умолчание:
type Box<T = string> = { value: T };
type Default = Box; // { value: string } — T взят по умолчанию
type Explicit = Box<Order>; // { value: Order }
Это тот же механизм, что параметр по умолчанию у функции — (value = 0) => ..., — поднятый на уровень типов. Аргумент, который не передали, заполняется заранее оговорённым типом.
Имена параметров условны — T не священно, это просто традиция, пришедшая из тех же джавовских <T>. Функция над типами читается ничуть не хуже с осмысленным именем, и чем сложнее функция, тем сильнее осмысленное имя выигрывает:
type ApiResponse<Payload> = {
status: number;
body: Payload;
};
type OrderResponse = ApiResponse<Order>;
Payload говорит, что за тип сюда подставляют, тогда как T не говорит ничего. К именованию мы ещё вернёмся в разделе про острые края — однобуквенные имена в нетривиальных функциях над типами обходятся дороже, чем кажется.
Функция над типами и функция над значениями в одном дженерике
До сих пор функция над типами и функция над значениями жили на разных этажах раздельно. Но самый частый дженерик в коде — тот, где они срастаются: дженерик-функция, то есть функция над значениями, параметризованная типом.
function identity<T>(value: T): T {
return value;
}
const a = identity<number>(42); // T = number, результат number
const b = identity<string>("ok"); // T = string, результат string
Здесь два слоя в одном объявлении. Внешний — функция над типами: identity принимает тип-параметр T. Внутренний — функция над значениями: получив T, она принимает значение типа T и возвращает значение типа T. identity<number> — это сначала применение функции над типами (подставили number вместо T), а получившееся (value: number) => number — уже обычная функция над значениями, которую вызывают с 42.
Связь между слоями — в том, что T связывает их воедино. Имя T, объявленное в угловых скобках, тут же используется в круглых: тип аргумента и тип возврата — один и тот же T. Функция над типами поставляет T функции над значениями, и та строит из него свою сигнатуру. Без дженерика пришлось бы либо фиксировать тип (и писать identity отдельно для чисел и строк), либо терять его через any. Дженерик позволяет сказать: «какой тип придёт, такой и уйдёт», — и это утверждение проверяется.
Полезность этого видна, как только в дело идёт что-то сложнее identity. Возьмём заказы:
type Order = {
id: string;
total: number;
status: "created" | "paid" | "shipped" | "delivered" | "cancelled";
items: { sku: string; qty: number }[];
};
function first<T>(items: T[]): T | undefined {
return items[0];
}
const orders: Order[] = [];
const head = first(orders); // Order | undefined
first работает с массивом любого типа элементов и обещает вернуть элемент того же типа. T связывает вход и выход: дайте массив заказов — получите заказ или undefined. Это и есть параметрический полиморфизм — одна реализация на все типы элементов, — но проще увидеть его как функцию над типами T ↦ (T[] ↦ T | undefined), которая по типу элемента строит сигнатуру конкретной функции.
Вывод аргументов типа
В примере с first есть деталь, которую легко проскочить: я нигде не написал first<Order>(orders). Написал first(orders) — и T всё равно стал Order. Компилятор вывел аргумент типа из аргумента-значения.
Это прямое продолжение разговора из главы 4 про вывод вместо объявления, поднятое на уровень дженериков. Аргумент типа — такая же вещь, которую можно либо объявить явно, либо доверить компилятору. И в случае дженерик-функций компилятор почти всегда решает сам, потому что у него есть откуда: тип значения, переданного в items, задаёт T однозначно.
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
const p = pair("id", 42); // A = string, B = number → [string, number]
// ↑ из этого выводится A
// ↑ из этого выводится B
Это и есть тот самый смысл, в котором дженерик — функция: аргументы задают параметры. Передаёте "id" в позицию a — и A, параметр функции над типами, определяется как string. Вывод аргументов типа — это не отдельный механизм, а то же применение функции, только компилятор сам восстанавливает, какие типы вы подставили, по форме того, что вы передали.
Поэтому идиоматичный TypeScript почти не пишет <T> в месте вызова. identity<number>(42) избыточно — identity(42) выведет T = number сам. Явный аргумент типа пишут там, где вывести его неоткуда.
А неоткуда — когда T не присутствует в аргументах-значениях. Если параметр типа фигурирует только в возврате, выводить его не из чего, и компилятор требует аннотацию:
function parse<T>(raw: string): T {
return JSON.parse(raw);
}
const x = parse("{}"); // T выведен как unknown — не из чего вывести
const order = parse<Order>("{}"); // T задан явно
В первом вызове T встречается только в типе возврата; единственный аргумент — string, и он ничего не говорит о T. Выводить нечего, и T схлопывается в unknown. Во втором вызове аргумент типа задан руками. Заодно это поучительный пример нечестного утверждения из главы 1: parse<Order> обещает Order, а JSON.parse вернёт что угодно — проверяющий поверит обещанию, рантайм его не сдержит. Но сейчас речь о выводе: его граница проходит ровно там, где параметр типа перестаёт встречаться в позициях аргументов.
Есть и тонкость на стыке с расширением литералов (глава 4). Что именно выведется для T, зависит от того, как компилятор расширяет литеральный тип переданного значения:
function wrap<T>(value: T): { value: T } {
return { value };
}
const a = wrap("paid"); // T = string (литерал расширен)
const b = wrap("paid" as const); // T = "paid" (расширение подавлено)
Тот же вызов даёт разный T в зависимости от того, попросили ли вы as const. Вывод аргумента типа наследует все правила вывода типа значения — это не два разных механизма, а один, работающий на двух этажах сразу.
Это вход в язык, а не просто коробка
Пока функции над типами, которые мы писали, только подставляли T в структуру — { value: T }, [A, B], { body: Payload }. Это полезно, но это ещё «коробка»: тип входит, тип кладётся в ячейку, тип выходит. Перепрошивка, ради которой написана глава, в том, что подстановкой дело не ограничивается. Функция над типами умеет вычислять — и именно здесь TypeScript уходит от всего, что джавист знал про дженерики.
Первый шаг за пределы коробки — ограничение параметра. Функция над значениями ограничивает свой аргумент типом: (n: number) => ... не примет строку. Функция над типами ограничивает свой аргумент через extends:
type IdOf<T extends { id: string }> = T["id"];
type X = IdOf<Order>; // string
type Y = IdOf<{ name: string }>; // ошибка: нет поля id, не подходит под границу
T extends { id: string } — это не наследование. Это условие на аргумент: «принимаю любой тип, у которого есть поле id типа string». Ограничение — превью главы 18; здесь важно, что внутри тела мы уже не просто кладём T в ячейку, а обращаемся к его структуре — T["id"] достаёт тип поля id. Функция над типами читает вход, а не только переупаковывает его.
А дальше, со следующими главами, открывается остальной инструментарий функций над типами, и каждый — обычная возможность функции, поднятая на уровень типов:
// ветвление по структуре аргумента (глава 19)
type ElementType<T> = T extends (infer U)[] ? U : T;
// преобразование каждого поля аргумента (глава 20)
type ReadonlyOrder = { readonly [K in keyof Order]: Order[K] };
ElementType — это if на уровне типов: смотрит, массив ли T, и возвращает разное. Сопоставленный тип (mapped type — преобразование, проходящее по всем ключам типа) — это цикл по полям. Условие, ветвление, цикл, рекурсия — функция над типами располагает ими всеми. Из этого и складывается язык программирования внутри системы типов, которому посвящена четвёртая часть.
Отсюда же — почему неограниченный T почти бесполезен. Если ничем не ограничить параметр, про него ничего не известно, и сделать с ним нечего, кроме как положить в ячейку:
function describe<T>(value: T): string {
return value.id; // ошибка: у T неизвестно поле id (и вообще ничего не известно)
}
Неограниченный T — это unknown уровня типов: верхний предел, про который компилятор не даёт никаких гарантий, кроме того, что это какой-то тип. Чтобы функция над типами могла что-то делать со своим аргументом, аргумент нужно ограничить — и ограничения станут темой следующей главы. Пока достаточно увидеть направление: дженерик — это не контейнер, а вход в язык, где типы вычисляют над типами.
Глазами джависта
На старте всё знакомо до неразличимости.
class Box<T>в Java иtype Box<T>в TypeScript делают одно: параметризуют структуру типом.<T extends Comparable<T>>ставит границу — у TS это<T extends ...>, буква в букву. Параметрический полиморфизм, ограниченные параметры, вывод аргументов типа в месте вызова (var list = List.of(...)) — всё это джавист уже умеет, и первые разделы этой главы для него — повторение пройденного под другим синтаксисом.Расхождение начинается там, где у Java нет продолжения. Дженерики Java классифицируют и ограничивают, но не вычисляют. Нет способа написать тип, который по
Tрешает, массив это или нет, и возвращает разное, — нет условных типов. Нет способа пройтись по полямTи сделать всеreadonly— нет сопоставленных типов. Нетinfer, нет сопоставления с образцом на уровне типов. Джавовский дженерик умеет сказать «контейнер чего-то, что являетсяComparable», но не «функция, которая из формы входа выводит форму выхода». Это потолок выразительности: дженерик в Java — параметр, не вычисление. В TypeScript он начинается так же и становится тьюринг-полным (почти) языком функций над типами. Вот граница, по которой два инструмента, тождественные на первой странице, расходятся необратимо.Вариантность две системы решают по-разному, и это показательно. Java управляет ею вручную через wildcards:
List<? extends Number>иList<? super Number>— программист каждый раз указывает направление совместимости. В TypeScript вариантность по большей части выводится из структуры типа — из того, стоит ли параметр в позиции чтения или записи, — и отдельного синтаксиса для каждого случая обычно не требуется. Номинативная система заставляет проговаривать вариантность; структурная её чаще всего вычисляет.И одно ограничение у двух языков общее — то самое стирание из нашего прежнего разговора. Дженерики Java стираются:
new T()запрещён,obj instanceof Box<String>невозможен — параметр типа в рантайме не существует. В TypeScript стирается всё, иTтем более: ни создатьT, ни проверитьinstanceof Box<string>нельзя по той же причине — к рантайму бирки нет. Здесь интуиция джависта переносится верно и без поправок. Стирание дженериков, которое в Java заперто в одном углу языка, в TypeScript — всеобщий закон, и предел, который джавист уже принял дляList<T>, в TS просто распространён на каждый параметр типа.
Где дженерики кусаются
Переформулировка «дженерик — это функция» соблазняет применять её всюду. Это ошибка ровно того же сорта, что писать функцию там, где хватило бы константы. Функция над типами оправдана, только когда что-то варьируется; в остальных случаях она добавляет шум и отнимает у компилятора ясность.
Самая частая болезнь — передженериченность. <T> появляется там, где конкретный тип или объединение сказали бы то же самое прямее:
// передженеричено: T используется ровно один раз, ничего не связывает
function logStatus<T extends string>(status: T): void {
console.log(status);
}
// честнее: тип сразу говорит, что принимается
function logStatus(status: Order["status"]): void {
console.log(status);
}
В первой версии T ничего не связывает — он встречается единожды, в одном аргументе, и нигде больше. Параметр типа, который не соединяет две позиции (вход с выходом, аргумент с аргументом), не выполняет работы функции над типами и существует впустую. Правило простое: не вводите <T>, пока что-то не варьируется и не требует связывания. Лишний параметр типа даёт худший вывод, более громоздкие сообщения об ошибках и иллюзию общности там, где общности нет.
Вторая ловушка — вывод решает T не всегда, и когда не решает, делает это молча неудачно. Мы видели parse, где T схлопнулся в unknown. Бывает хуже — когда выводится не то, что вы имели в виду, из-за расширения литералов:
function makeState<T>(initial: T): { value: T } {
return { value: initial };
}
const s = makeState("created"); // T = string, а не "created"
s.value = "что угодно"; // компилируется — а вы, возможно, хотели только статусы
Компилятор расширил "created" до string, и T стал шире, чем нужно. Никакой ошибки — просто гарантия слабее ожидаемой. Чтобы получить литеральный T, нужен либо as const на вызове, либо ограничение <T extends string>, которое подавляет расширение. Вывод — удобство, но он следует своим правилам, а не вашим намерениям, и иногда эти правила работают против вас.
Третье уже названо, но повторю как край: неограниченный T бесполезен. Пока параметр ничем не ограничен, тело функции над типами не может ни обратиться к его полям, ни вызвать его методы — про него попросту ничего не известно. Полезные функции над типами почти всегда ограничены; ограничения — глава 18, и без них дженерик остаётся пустой коробкой.
Четвёртый край — фундаментальный и идёт прямо из главы 3: рантайм слеп к T. Параметр типа стёрт, и значит, поведение программы не может зависеть от T напрямую. Нельзя написать new T(), нельзя проверить value instanceof T, нельзя ветвиться по T в рантайме:
function create<T>(): T {
return new T(); // ошибка: 'T' — только тип, в рантайме его нет
}
Если поведение должно зависеть от типа-параметра, придётся носить с собой рантайм-значение, несущее ту же информацию, — конструктор, тег, схему валидации:
function create<T>(ctor: new () => T): T {
return new ctor(); // ok: ctor — настоящее значение, переживает стирание
}
T сообщает проверяющему, что вернётся; ctor сообщает рантайму, что создать. Разрыв между двумя программами здесь во всей красе: тип не доходит до рантайма, поэтому всё, что рантайму нужно знать о типе, передают значением. Это не обходной манёвр, а прямое следствие стирания — и тема, к которой пятая часть вернётся всерьёз.
И последнее, помельче, но не пустяк: однобуквенные имена. <T> терпимо для identity, но в функции над типами с тремя параметрами и нетривиальным телом <T, U, V> превращают чтение в дешифровку. Имя параметра типа — единственное, что объясняет его роль; <Payload>, <Element>, <Key> стоят ровно столько же символов в объявлении и экономят читателю реконструкцию смысла. Функция над типами — такой же код, который будут читать, и читаемость её параметров — не косметика.
Читай Foo<T> как функцию: что берёт, что возвращает
Сдвиг, ради которого написана глава, формулируется одной фразой: встретив Foo<T>, спрашивайте не «что это за коробка», а «что эта функция берёт и что возвращает». Угловые скобки — список аргументов. Тело определения — вычисление над ними. Результат — тип. Как только этот вопрос становится автоматическим, четвёртая часть книги перестаёт быть набором трюков и оказывается одним понятием в разных обличьях.
Из этого вытекает дисциплина проектирования — та же, что для функций над значениями, перенесённая на этаж выше. Хорошая функция над типами имеет ясные входы: параметры названы по роли, а не буквами из середины алфавита. Имеет ограничения: extends сужает аргумент до того, с чем тело реально умеет работать, — неограниченный параметр так же подозрителен, как any в сигнатуре. Решает одну задачу: Foo<T>, делающий три не связанных между собой вещи, так же плох, как функция на сто строк. Компонуется: результат одной функции над типами становится аргументом другой, и из мелких функций собираются крупные — ровно как из функций над значениями.
И четыре практических следствия, каждое уже выведенное выше:
- Не добавляйте
<T>, пока что-то не варьируется и не требует связывания двух позиций. Параметр типа, встречающийся единожды, — почти всегда лишний. - Дайте выводу поставлять аргументы типа. Пишите
first(orders), а неfirst<Order>(orders); явный аргумент типа — только там, где вывести неоткуда. - Аннотируйте
Tруками ровно там, где компилятор не решает его сам или решает не так, как нужно, — в возвратных позициях и при нежелательном расширении. - Помните, что рантайм слеп к
T. Если поведение зависит от типа-параметра, носите с собой значение — конструктор, тег, схему, — потому что самTдо рантайма не доедет.
Проектировать функции над типами — это проектировать функции. Те же добродетели, те же грехи, тот же вкус. Просто аргументы здесь — типы.
Итог
Дженерик — это функция, берущая типы и возвращающая типы: угловые скобки суть список аргументов, тело определения — вычисление, результат — тип. Кто читает Foo<T> так, для того вся четвёртая часть — не магия условных, сопоставленных и рекурсивных типов, а обыкновенное применение и композиция функций над типами.