← Глава 16. Композиция против иерархии Содержание Глава 18. Ограничения и вывод параметров →

Глава 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>, делающий три не связанных между собой вещи, так же плох, как функция на сто строк. Компонуется: результат одной функции над типами становится аргументом другой, и из мелких функций собираются крупные — ровно как из функций над значениями.

И четыре практических следствия, каждое уже выведенное выше:

Проектировать функции над типами — это проектировать функции. Те же добродетели, те же грехи, тот же вкус. Просто аргументы здесь — типы.

Итог

Дженерик — это функция, берущая типы и возвращающая типы: угловые скобки суть список аргументов, тело определения — вычисление, результат — тип. Кто читает Foo<T> так, для того вся четвёртая часть — не магия условных, сопоставленных и рекурсивных типов, а обыкновенное применение и композиция функций над типами.

← Глава 16. Композиция против иерархии Содержание Глава 18. Ограничения и вывод параметров →