← Глава 18. Ограничения и вывод параметров Содержание Глава 20. Сопоставленные типы →

Глава 19. Условные типы и infer

Глава 19. Условные типы и infer

Тип, который смотрит внутрь другого типа

В нашем магазине обработчик заказа умеет возвращать результат двумя способами: иногда синхронно, иногда — обёрнутым в Promise, потому что под капотом ходит в платёжный шлюз. Хочется одну функцию, которая нормализует ответ: если ей дали Promise<Order>, она дожидается и отдаёт Order; если дали сам Order — отдаёт как есть. Тип результата зависит от того, что именно ей передали.

Запишем тип этой зависимости. На вход — T, на выход — «то, во что T разрешается, если T это Promise, иначе сам T». Попробуйте сформулировать это любым инструментом, который вы приносили из статически типизированного мира, и вы упрётесь в стену. Перегрузки опишут два отдельных случая, но не выведут один из другого. Дженерик-параметр <T> обозначит вход, но не даст вынуть из Promise<...> то, что внутри. Чтобы добраться до начинки Promise, в Java вы потянулись бы к рантайм-рефлексии — и обнаружили бы, что из-за стирания доставать там нечего: параметр типа выброшен. Выразить «тип содержимого Promise» как тип — нечем.

В TypeScript это одна строка:

type Unwrap<T> = T extends Promise<infer U> ? U : T;

type A = Unwrap<Promise<Order>>; // Order
type B = Unwrap<Order>;          // Order

Прочтите её медленно, потому что здесь происходят сразу две незнакомые вещи. Сначала T extends Promise<...> ? ... : ... — это проверка: тип спрашивает, укладывается ли T в форму Promise, и выбирает одну из двух веток. Затем infer U внутри Promise<infer U> — это захват: на месте параметра Promise мы не пишем конкретный тип, а ставим переменную U и говорим компилятору «выведи, что здесь стоит, и дай мне это в истинной ветке».

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

Условный тип — это if над типами

Начнём с ветвления и временно забудем про infer. Синтаксис условного типа (conditional type) — типа, который выбирает одну из двух веток по условию, — буквально повторяет тернарный оператор JavaScript, только над типами:

type IsString<T> = T extends string ? "да" : "нет";

type X = IsString<"paid">; // "да"
type Y = IsString<number>; // "нет"

Условие здесь — T extends string. Это тот же extends, что в главе 18 задавал границу дженерика, и значит он то же самое: «T присваиваем string», то есть в терминах множеств из главы 6 — «множество T вложено в множество string». Разница в роли. В главе 18 extends стоял в объявлении параметра и работал как ворота: не уложился — ошибка компиляции. Здесь extends стоит в теле типа и работает как булева проверка: уложился — берём левую ветку, не уложился — правую. Граница превратилась в вопрос.

Литерал "paid" присваиваем string — значит, IsString<"paid"> вычисляется в "да". number строке не присваиваем — IsString<number> даёт "нет". Никакого кода это не порождает: вычисление целиком происходит в голове у проверяющего во время компиляции, а результат — обычный тип, которым можно пользоваться дальше.

Ветки не обязаны быть строками-метками; обычно они и есть полезная нагрузка. Вернёмся к нормализации ответа, но пока без infer — просто выберем тип:

type Order = {
  id: string;
  total: number;
  status: "created" | "paid" | "shipped" | "delivered" | "cancelled";
  items: { sku: string; qty: number }[];
};

// если T — это массив, работаем с ним как с пачкой, иначе как с одиночкой
type AsBatch<T> = T extends unknown[] ? T : T[];

type C = AsBatch<Order>;   // Order[]
type D = AsBatch<Order[]>; // Order[]

AsBatch приводит вход к массиву: одиночный Order оборачивает, готовый массив оставляет. Условие проверило форму, ветки вернули разные типы. Это if над типами в чистом виде — и уже на нём строится масса полезного. Но самое интересное начинается, когда T оказывается не одним типом, а объединением.

Распределение по объединению

Спросим у условного типа что-нибудь про объединение (union — тип-сумму из главы 7, значение одного из перечисленных типов). Уберём из статусов заказа все «конечные» состояния — оставим только те, из которых заказ ещё может куда-то двигаться:

type Status = "created" | "paid" | "shipped" | "delivered" | "cancelled";
type Terminal = "delivered" | "cancelled";

type Active<T> = T extends Terminal ? never : T;

type Result = Active<Status>;
// "created" | "paid" | "shipped"

Остановитесь на результате. Status — это объединение из пяти членов, и целиком оно Terminal не присваиваемо. По букве предыдущего раздела проверка Status extends Terminal должна была дать false и вернуть весь Status. Вместо этого условие применилось к каждому члену объединения по отдельности, а результаты собрались обратно в объединение. "created" не Terminal — остаётся. "delivered" — Terminal, превращается в never. А never в объединении исчезает бесследно (это нижний тип, пустое множество; глава 11). Пять веток сложились в три уцелевших.

Это распределяющийся условный тип (distributive conditional type). Правило точное: если проверяемый тип — голый параметр (naked type parameter, то есть сам T, а не T[], не Promise<T>, не что-либо вокруг него) и в него подставили объединение, условие применяется к каждому члену объединения порознь, а ветки объединяются. Условный тип ведёт себя как цикл map по членам союза.

Именно из этого, без всякой магии, собраны встроенные утилиты:

// ровно то, что в стандартной библиотеке:
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
type NonNullable<T> = T extends null | undefined ? never : T;

type Open = Exclude<Status, Terminal>;     // "created" | "paid" | "shipped"
type Done = Extract<Status, Terminal>;     // "delivered" | "cancelled"
type Sure = NonNullable<string | null>;    // string

Exclude — это наш Active дословно. Extract — он же с переставленными ветками. NonNullable выкидывает из объединения null и undefined. Три строки, и всё держится на одном поведении: голый параметр раскладывается по объединению.

Раскладывается он не всегда кстати. Иногда нужно проверить объединение целиком, как единый тип, а не почленно. Распределение отключается обёрткой проверяемого типа (и сравниваемого — за компанию) в одноэлементный кортеж:

// распределяющийся: каждый член проверяется отдельно
type Distr<T> = T extends Terminal ? "term" : "open";
type R1 = Distr<Status>; // "term" | "open"  — оба исхода смешались

// нераспределяющийся: Status проверяется как одно целое
type Whole<T> = [T] extends [Terminal] ? "term" : "open";
type R2 = Whole<Status>; // "open"  — весь Status целиком не Terminal

[T] — это уже не голый параметр, а кортеж вокруг него, поэтому распределение не включается, и Status сравнивается с Terminal как один тип. Приём [T] extends [U] стоит запомнить отдельной зарубкой: это переключатель «проверяй всё целиком».

И ловушка, которую распределение прячет: что будет, если в распределяющийся условный тип подставить never?

type Touch<T> = T extends string ? "была строка" : "не строка";

type N = Touch<never>; // never  — а не "не строка"

never — это пустое объединение, союз без единого члена. Распределение проходит по членам — а проходить не по чему, ноль итераций, — и результат пуст, то есть never. Условие даже не вычислялось. Если внутри какого-нибудь типа T иногда схлопывается в never, ваша условная логика на нём тихо вернёт never, а не ту ветку, которую вы ожидали глазами. Это не редкость на практике: never легко получается из Exclude, убравшего всё, или из пересечения несовместимых типов.

infer — сопоставление с образцом

Теперь вернём вторую половину — захват. infer появляется только внутри проверяемой формы условного типа и означает: «здесь стоит какой-то тип; выведи его и свяжи с этим именем, чтобы я воспользовался им в истинной ветке».

Образцовый случай — достать тип элемента массива:

type ElementOf<T> = T extends (infer E)[] ? E : never;

type E1 = ElementOf<Order[]>;            // Order
type E2 = ElementOf<{ sku: string }[]>;  // { sku: string }
type E3 = ElementOf<number>;             // never  — не массив

(infer E)[] — это образец: «массив чего-то, и это что-то назови E». Компилятор сопоставляет T с образцом; совпало — в истинной ветке доступен E, связанный с тем, что стояло на месте дырки. Не совпало — берётся ложная ветка, где E уже не существует.

Это сопоставление с образцом (pattern matching): вы описываете форму типа с дыркой, ставите в дырку infer, и компилятор связывает дырку с тем, что туда подошло. Образец может быть любой формы, какую система типов умеет писать. Достанем тип, в который разрешается Promise:

type Awaited1<T> = T extends Promise<infer U> ? U : T;

type W1 = Awaited1<Promise<Order>>; // Order
type W2 = Awaited1<Order>;          // Order

Достанем тип возвращаемого значения функции — infer ставится в позицию результата стрелочного типа:

type Returns<T> = T extends (...args: any[]) => infer R ? R : never;

function pay(order: Order): { ok: boolean; charged: number } {
  return { ok: true, charged: order.total };
}

type Charge = Returns<typeof pay>; // { ok: boolean; charged: number }

Образец (...args: any[]) => infer R читается как «любая функция, чей результат назови R». Аргументы нам неинтересны — any[] пропускает их, не глядя. typeof pay берёт тип функции pay, образец вынимает из него тип возврата. Это и есть встроенный ReturnType, дословно.

Заметьте, что во всех трёх случаях мы не вызывали ничего, не запускали, не рефлексировали. Мы описали форму с дыркой и попросили компилятор связать дырку. Promise<infer U>, (infer E)[], (...) => infer R — это три образца под три формы, и захват в каждом работает одинаково: распознать форму, выхватить кусок.

Несколько infer, и почему утилиты — это просто оно

Образец может содержать не одну дырку. Достанем сразу и аргументы, и результат функции — два infer в одном образце:

type Signature<T> = T extends (...args: infer A) => infer R
  ? { args: A; result: R }
  : never;

type S = Signature<typeof pay>;
// { args: [order: Order]; result: { ok: boolean; charged: number } }

infer A встал в позицию списка параметров и связался с кортежем аргументов — это встроенный Parameters. infer R связался с результатом. Оба доступны в истинной ветке одновременно.

Дырку можно ограничить — написать infer R extends U, и захват сработает, только если выведенное укладывается в границу. Это фильтр прямо в образце:

// взять первый элемент кортежа, только если это строка
type FirstString<T> =
  T extends [infer H extends string, ...unknown[]] ? H : never;

type F1 = FirstString<["sku-1", number]>; // "sku-1"
type F2 = FirstString<[number, string]>;  // never — голова не строка

В кортежах infer особенно нагляден: образец разбирает кортеж на голову и хвост ровно как деструктуризация массива в рантайме, только над типами.

type Head<T> = T extends [infer H, ...unknown[]] ? H : never;
type Tail<T> = T extends [unknown, ...infer Rest] ? Rest : never;

type H = Head<[Order, string, number]>; // Order
type R = Tail<[Order, string, number]>; // [string, number]

Остаётся одна неочевидная тонкость — что происходит, когда одна и та же infer-переменная встречается в нескольких местах. Результат зависит от позиции вывода, и зависит он так же, как вариантность из теории подтипов, только её здесь не нужно знать по имени, достаточно увидеть исход:

// две ковариантные позиции (типы свойств) → объединение
type Co<T> = T extends { a: infer U; b: infer U } ? U : never;
type CoRes = Co<{ a: string; b: number }>; // string | number

// две контравариантные позиции (типы аргументов) → пересечение
type Contra<T> = T extends {
  f: (x: infer U) => void;
  g: (x: infer U) => void;
} ? U : never;
type ContraRes = Contra<{
  f: (x: string) => void;
  g: (x: number) => void;
}>; // string & number

Когда одну переменную выводят из нескольких ковариантных позиций (там, где тип «выдаётся наружу», — свойства, возврат), кандидаты объединяются. Когда из нескольких контравариантных (там, где тип «принимается внутрь», — аргументы функций), кандидаты пересекаются. Правило можно не заучивать формулировкой; достаточно держать в голове, что несколько infer под одним именем не обязаны давать один тип, и что «выходные» позиции дают union, а «входные» — intersection.

Соберём картину. Встроенные ReturnType, Parameters, Exclude, Extract, NonNullable, Awaited — это всё несколько строк ровно из показанного: условный тип ветвит, infer захватывает, распределение проходит по объединению. Полную разборку стандартной библиотеки мы отложим до главы 23, но магии там не будет — будут эти же примитивы. А добавив рекурсию из главы 22 (условный тип, ссылающийся на себя), вы получаете полноценную вычислительную модель: ветвление, захват и повторение — всё, что нужно языку, чтобы считать.

Глазами джависта

Стоит остановиться и признать прямо: у Java на уровне типов этого нет. Не «есть, но иначе» — нет вовсе. Нельзя написать тип, который ветвится по другому типу, и нельзя вынуть «тип элемента списка» или «тип возврата метода» как тип, которым потом пользуешься в сигнатуре. Дженерики Java — параметрический полиморфизм: вы передаёте тип-аргумент, но не вычисляете типы из типов. Условие над типом и захват куска типа — операции, для которых в системе типов Java нет ни синтаксиса, ни понятия.

Ближайшее, к чему тянется рука, — рефлексия: method.getReturnType(), getGenericParameterTypes(). Но это другая вселенная по двум осям сразу. Во-первых, рефлексия работает с рантайм-значениями (Class<?>, Type), а не с типами этапа компиляции: её результат — объект, который существует при исполнении, а не тип, который компилятор подставит в сигнатуру. Из getReturnType() нельзя объявить переменную нужного типа — компилятор о нём ничего не узнает. Во-вторых, она упирается в то же стирание, что и весь остальной язык: List<String>.class неотличим от List<Integer>.class, параметр уже выброшен, доставать через рефлексию ровно ту начинку, которую infer достаёт статически, попросту нечего.

Не помогает и сопоставление с образцом, которое в Java наконец появилось (switch по sealed-иерархии, деконструкция records). Оно сопоставляет значения во время исполнения: смотрит на объект и разбирает его на поля в рантайме. infer сопоставляет типы во время компиляции и не оставляет в рантайме ничего. Совпадение в слове «сопоставление» обманчиво: одно — динамическая операция над данными, другое — статическое вычисление над формами.

Здесь интуиция «да это же просто дженерики, как у меня, только синтаксис чуднóй» рушится окончательно. До этой главы можно было держаться за неё, кое-как натягивая. T extends U ? X : Y и infer натянуть не на что: соответствующего инструмента в руках джависта не было никогда.

Где условные кусаются

Глава обещает острые края, и здесь их хватает — эта мощь оплачена.

Распределение срабатывает молча и не всегда туда. Голый параметр над объединением раскладывается почленно, даже когда вы хотели проверить объединение целиком. Симптом — результат внезапно сам оказывается объединением там, где вы ждали один тип. Лечение — [T] extends [U], отключающее распределение. Беда в том, что забыть про распределение легко, а его след в результате неброский.

never распределяется в never. Разобрано выше, но повторю как ловушку: распределяющийся условный тип на входе never возвращает never, не вычислив ни одной ветки, потому что распределять не по чему. Если T где-то по дороге схлопнулся в пустое объединение, ваша логика тихо вернёт пустоту вместо ожидаемой ветки.

Несколько infer под одним именем дают union или intersection в зависимости от позиции. Ковариантные позиции объединяют кандидатов, контравариантные — пересекают. Если не держать это в голове, тип, который «должен быть один», окажется суммой или произведением, и неочевидно почему.

boolean раскладывается как true | false. Внутри boolean живёт объединение двух литералов, и распределяющийся условный тип это видит:

type Test<T> = T extends true ? "T" : "F";
type Both = Test<boolean>; // "T" | "F", а не один член

Ждали один исход — получили оба, потому что boolean распался на члены.

any уходит в обе ветки сразу. Подстановка any в проверяемую позицию даёт объединение обеих веток — прямое следствие того, что any присваиваем и не присваиваем всему одновременно (глава 11):

type IsStr<T> = T extends string ? "да" : "нет";
type Huh = IsStr<any>; // "да" | "нет"

Это диагностически полезно — any, протёкший в условный тип, выдаёт себя раздвоением результата, — но застаёт врасплох.

Сложность и пределы рекурсии. Условные типы с infer и самоссылкой — это вычисление, и у него есть стоимость: глубокая рекурсия упирается в лимит вложенности компилятора и роняет проверку с Type instantiation is excessively deep and possibly infinite. Где язык останавливается и где должны остановиться вы — тема главы 22.

Сообщения об ошибках непрозрачны, вложенность нечитаема. Когда условный тип не сошёлся, компилятор часто показывает не «почему», а раскрытый промежуточный тип на пол-экрана. А глубоко вложенные условные (extends ? : extends ? : ...) читаются тяжело и быстро становятся write-only-кодом. Это реальная цена выразительности, и платится она читаемостью.

Тестируй, деструктурируй, верни — как маленькая чистая функция

Сдвиг, который должен щёлкнуть: условный тип с infer — это маленькая чистая функция над типами, и думать о ней стоит ровно так.

У этой функции три такта. Тест — T extends U: проверка присваиваемости, тот же extends, что задавал границу в главе 18, теперь в роли условия. Деструктуризация — infer: вы описываете форму с дыркой и называете дырку, а компилятор связывает её тем, что подошло; «опиши форму с дыркой, назови дырку» — вся механика захвата в одной фразе. Возврат — одна из двух веток как результат. Тест, разбор, отдача. Если умеете писать function, вы умеете читать условный тип — синтаксис другой, схема та же.

Две дисциплины поверх этого. Первая — всегда сознавать распределение. Прежде чем писать T extends ..., ответьте себе: T тут может быть объединением? Если да — вы хотите пройтись по членам или проверить целиком? Хотите почленно — оставляйте голый параметр, это и есть механизм Exclude/Extract. Хотите целиком — оборачивайте в [T]. Молчаливое распределение перестаёт кусаться ровно тогда, когда вы держите этот вопрос в голове по умолчанию.

Вторая — не вкладывать без меры. Двух-трёх-этажный условный тип ещё читается; глубже — это запах, и почти всегда он означает, что задачу пора разложить на именованные псевдонимы типов или что вы дрейфуете к рекурсии, которой место в главе 22. Сила соблазняет; считайте нечитаемый условный тип таким же дефектом, как нечитаемую рантайм-функцию.

И последнее, ради чего всё это. Эти две конструкции — extends-тест и infer-захват — и делают встроенные утилиты обычным кодом, а не встроенной магией. ReturnType, Parameters, Exclude, Awaited перестают быть чёрными ящиками из стандартной библиотеки в тот момент, когда вы видите, что напишете каждую из них сами в три строки. Это и есть момент, когда система типов окончательно перестаёт быть классификатором значений и становится языком, на котором вы программируете.

Итог

Условный тип ветвит — T extends U ? X : Y проверяет присваиваемость и выбирает ветку; infer разбирает — выхватывает кусок из сопоставленной формы в переменную. Кто владеет этой парой, у того система типов больше не таксономия классов, а язык, на котором типы вычисляются из типов.

← Глава 18. Ограничения и вывод параметров Содержание Глава 20. Сопоставленные типы →