← Глава 19. Условные типы и infer Содержание Глава 21. Шаблонные литеральные типы →

Глава 20. Сопоставленные типы

Глава 20. Сопоставленные типы

Order, но все поля необязательны

Есть тип заказа. Он точный, полный, ничего лишнего:

type Order = {
  id: string;
  total: number;                 // сумма в минимальных единицах валюты
  status: "created" | "paid" | "shipped" | "delivered" | "cancelled";
};

А теперь нужен PATCH-эндпоинт: клиент присылает только те поля, которые хочет изменить. Сегодня меняем status, завтра — total, послезавтра — оба сразу. Тело запроса — это Order, у которого любое поле может отсутствовать, но если уж присутствует, то ровно того типа, что в оригинале. id — string, total — number, и так далее.

Рефлекс, принесённый из статически типизированного мира, подсказывает написать второй тип руками:

type OrderPatch = {
  id?: string;
  total?: number;
  status?: "created" | "paid" | "shipped" | "delivered" | "cancelled";
};

Работает. И разваливается на второй неделе. В Order добавят поле items — а в OrderPatch про него забудут, потому что это отдельный тип в другом месте файла, и компилятор не связывает их ничем. Тип status поменяют в одном месте, а в копии останется старое объединение. OrderPatch — это Order, переписанный заново, с обязательством руками держать две формы в синхроне до конца проекта. Каждое поле продублировано, и каждое продублированное поле — это будущее расхождение.

Чего хочется на самом деле — сказать одной фразой: «возьми Order и сделай каждое его поле необязательным». Не переписать форму, а вывести её из исходной. Чтобы добавленное в Order поле items появилось в патче само, а изменённый тип status подтянулся туда же без единого касания.

В TypeScript это пишется так:

type OrderPatch = { [K in keyof Order]?: Order[K] };

И этого достаточно. Здесь нет повторённых имён полей, нет скопированных типов, нет второй формы, которую надо синхронизировать. Есть инструкция: пройди по ключам Order, и на каждый построй необязательное свойство того же типа. Добавьте в Order поле items — оно немедленно появится в OrderPatch необязательным, потому что OrderPatch не описывает форму, а вычисляет её из Order при каждой проверке.

Конструкция в скобках называется mapped type — сопоставленный тип. И в стандартной библиотеке она уже есть под именем Partial:

type OrderPatch = Partial<Order>; // ровно то же самое

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

Сопоставленный тип — это for по ключам

Начнём с самого скучного, что можно написать, — отображения, которое ничего не меняет.

type Identity<T> = { [K in keyof T]: T[K] };

Разберём по частям. keyof T — это объединение строковых литералов, имён всех ключей T (глава 18); для Order это "id" | "total" | "status". Конструкция [K in keyof T] перебирает это объединение по одному члену, связывая K с каждым ключом поочерёдно — буквально for-each по ключам типа. А T[K] — индексный доступ: тип свойства под ключом K. Когда K равен "total", T[K] равно number.

Сложите всё вместе: для каждого ключа K из keyof T построй свойство с именем K и типом T[K]. То есть скопируй T свойство за свойством. Identity<Order> — это снова Order, поле в поле:

type Copy = Identity<Order>;
// { id: string; total: number; status: "created" | "paid" | ... }

Тождественное отображение само по себе бесполезно, но оно показывает скелет: левая часть, [K in keyof T], задаёт, по каким ключам мы идём; правая, после двоеточия, задаёт, какой тип получит каждое свойство. Вся сила — в том, что правая часть это не обязательно T[K]. Это любое выражение типа, в котором доступен K. Значит, можно преобразовывать.

Сделаем тривиальное преобразование: пусть каждое поле станет boolean. Получится «карта присутствия» — по полю на каждое поле исходника, но все типа boolean:

type Flags<T> = { [K in keyof T]: boolean };

type OrderFlags = Flags<Order>;
// { id: boolean; total: boolean; status: boolean }

Имена ключей сохранились — мы по-прежнему идём по keyof Order. А типы значений мы выкинули и подставили свои. Это уже не копия: это новая форма, механически выведенная из формы Order.

Преобразование не обязано быть константой. В теле доступен T[K] — исходный тип свойства, — и его можно прогнать через другой тип. Допустим, мы хотим завернуть каждое поле в массив:

type Arrayify<T> = { [K in keyof T]: T[K][] };

type OrderArrays = Arrayify<Order>;
// { id: string[]; total: number[]; status: ("created" | "paid" | ...)[] }

Тип status остался самим собой внутри массива — потому что T[K] подставил именно его, каким он был. Отображение не знает и не хочет знать, что за тип под ключом; оно просто берёт T[K] и применяет к нему преобразование []. Замените [] на любую обёртку — Promise<T[K]>, T[K] | null, вызов условного типа Transform<T[K]> — и получите соответствующую перешитую форму. Ключи те же, типы свойств — пропущенные через функцию.

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

Модификаторы: readonly и ? через + и -

Partial из начала главы делал поля необязательными. Необязательность — это ? после имени свойства. В сопоставленном типе ? ставится не на каждый ключ руками, а один раз, на всё отображение:

type Partial<T> = { [K in keyof T]?: T[K] };
//                                ^ применится к каждому свойству

Один знак ? после [K in keyof T] означает: каждое порождённое свойство будет необязательным. Это и есть вся реализация Partial из стандартной библиотеки — строка в строку. То же с неизменяемостью: модификатор readonly перед ключом делает readonly-копию, и это Readonly из стандартной библиотеки:

type Readonly<T> = { readonly [K in keyof T]: T[K] };

type FrozenOrder = Readonly<Order>;
// { readonly id: string; readonly total: number; readonly status: ... }

Пока мы только добавляли модификаторы. Интереснее — снимать. Обратная задача к Partial: есть тип с необязательными полями, нужен тип, где все они обязательны. Для этого служит знак - перед модификатором — он его убирает:

type Required<T> = { [K in keyof T]-?: T[K] };
//                                 ^^ снять необязательность с каждого свойства

-? читается как «минус необязательность»: с каждого свойства снимается ?, даже если в исходнике оно было опциональным. Симметрично -readonly снимает неизменяемость — так пишется Mutable, которого в стандартной библиотеке нет, но который собирается за секунду:

type Mutable<T> = { -readonly [K in keyof T]: T[K] };

type ThawedOrder = Mutable<FrozenOrder>;
// { id: string; total: number; status: ... } — readonly снят

Знак + тоже существует и означает «добавить модификатор», но он избыточен: +? и ? — одно и то же, +readonly и readonly — одно и то же. Голый модификатор уже добавляет. На практике пишут ? и readonly для добавления, -? и -readonly для снятия, а + не пишут почти никогда.

Соберём картину. Четыре утилиты — Partial, Required, Readonly, Mutable — это один и тот же цикл [K in keyof T]: T[K], отличающийся только тем, какой модификатор он добавляет или снимает. Изменяемость и опциональность переключаются декларативно, по всем полям разом, одним символом. Не «для каждого поля поставь ?», а «это отображение делает поля необязательными» — и компилятор сам разносит это по всем ключам, сколько бы их ни было и какие бы ни добавили потом.

Переименование и фильтрация ключей через as

До сих пор имена ключей мы не трогали: шли по keyof T и каждому порождённому свойству давали то же имя K. Но имя порождённого ключа можно переопределить — оговоркой as в левой части:

type Renamed<T> = { [K in keyof T as `new_${string & K}`]: T[K] };

После as стоит тип нового ключа. Здесь использован шаблонный литеральный тип (template literal type — манипуляция строками в системе типов, глава 21): к каждому имени приклеивается префикс new_. Связка string & K нужна потому, что ключ формально может быть string | number | symbol, а в шаблон подставляется строковая часть. Результат — та же форма с переименованными полями:

type X = Renamed<Order>;
// { new_id: string; new_total: number; new_status: ... }

Сам по себе префикс — игрушка. Но as открывает второй, более важный ход. Если новый ключ вычислить как never, свойство исчезает. Отображение в never — это удаление ключа. А раз новый ключ можно выбирать условным типом (глава 19), мы получаем фильтрацию полей по их типу.

Оставим в Order только поля типа string:

type StringKeysOnly<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type S = StringKeysOnly<Order>;
// { id: string; status: "created" | "paid" | ... }
//   total отброшено: number не присваивается string

Для каждого ключа K условие T[K] extends string спрашивает: тип этого свойства — строка? Если да, ключ остаётся собой; если нет, он становится never и свойство выпадает. total имеет тип number, number extends string ложно, ключ превращается в never, поля total в результате нет. Фильтр по типу значения, выраженный целиком в системе типов.

Тот же приём в связке с шаблонными литералами даёт переименование осмысленное, а не косметическое, — например, превратить поля в геттеры:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type OrderGetters = Getters<Order>;
// { getId: () => string; getTotal: () => number; getStatus: () => ... }

Из поля id: string родился метод getId: () => string. Имя собрано шаблоном с Capitalize (встроенный строковый тип), значение завёрнуто в тип функции без аргументов. Из плоской записи описания данных мы вывели интерфейс доступа к ним — не написав ни одного имени метода руками.

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

Гомоморфность и Record

Все отображения выше начинались одинаково: [K in keyof T]. Это не просто частый случай — это особая форма, и компилятор обращается с ней по-особому. Отображение по keyof T называется гомоморфным (homomorphic — сохраняющим структуру): оно идёт по ключам конкретного типа T и потому помнит, откуда взялся каждый ключ. Из этого вытекает важное свойство — оно сохраняет модификаторы исходника.

Посмотрим. Пусть в заказе есть readonly-поле и необязательное:

type Account = {
  readonly id: string;
  balance: number;
  note?: string;
};

type Wrapped = { [K in keyof Account]: Account[K] };
// { readonly id: string; balance: number; note?: string }
//   readonly и ? сохранились сами, мы их не писали

Мы не добавляли ни readonly, ни ? — а они на месте. Гомоморфное отображение по умолчанию переносит модификаторы каждого свойства из исходника. Именно поэтому Partial<Account> делает все поля необязательными, но readonly id остаётся readonly: Partial лишь добавляет ?, а исходный readonly он сохраняет, потому что гомоморфен. Это не случайность, а то, ради чего форма keyof T обрабатывается отдельно.

Теперь противоположность. Левая часть не обязана быть keyof T — там может стоять любое объединение строк. И на этом построен Record:

type Record<K extends string, V> = { [P in K]: V };

type StatusCounts = Record<Order["status"], number>;
// { created: number; paid: number; shipped: number; delivered: number; cancelled: number }

Здесь [P in K] идёт не по ключам существующего типа, а по произвольному объединению K, переданному параметром. Такое отображение негомоморфно: оно строит форму с нуля по списку имён, ему неоткуда брать чужие модификаторы, потому что нет исходного типа, чьи свойства можно было бы помнить. Record<Order["status"], number> сопоставляет каждому статусу число — удобно для счётчиков или таблицы переходов, — и никаких readonly/? тут не наследуется, их попросту неоткуда взять.

Различие гомоморфного и негомоморфного отображения — не педантизм. От него зависит, сохранятся модификаторы или испарятся, и как поведёт себя отображение на массивах и кортежах (об этом — в острых краях). Практическое правило простое: идёшь по полям существующего типа — пиши keyof T, и структура сохранится; строишь форму по списку имён — это Record-образный случай, структуры там нет по определению.

Последний штрих — когда полю нужна не общая обёртка, а индивидуальная логика, зависящая от его собственного типа. Тогда в правую часть ставят условный тип с infer (глава 19), и решение принимается на каждое свойство отдельно. Развернём промисы: поля-Promise заменим на то, что внутри, остальные оставим как есть:

type Unwrap<T> = {
  [K in keyof T]: T[K] extends Promise<infer U> ? U : T[K];
};

type Resolved = Unwrap<{ id: string; user: Promise<Order> }>;
// { id: string; user: Order }

Поле id не Promise — условие ложно, остаётся string. Поле user — Promise<Order>, infer U ловит Order, и свойство становится Order. Цикл по ключам снаружи, ветвление по типу свойства внутри: сопоставленный тип задаёт обход, условный — что делать с каждым встреченным типом.

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

Поставьте ту же задачу в Java: есть класс Order, нужен тип «тот же Order, но все поля необязательны» — и пусть он сам подстраивается, когда в Order добавят поле. Java на это не способна на уровне типов. Компилятор Java не умеет вывести один тип из другого преобразованием его членов; типа, который вычисляется из формы другого типа, в языке нет вовсе.

Поэтому за тем же результатом Java тянется к кодогенерации. «Все поля необязательны», билдер, набор геттеров, копия класса с другими модификаторами — это процессоры аннотаций: Lombok, MapStruct и им подобные читают класс при сборке и эмитят настоящий исходный код второго класса. Сгенерированный файл реален, его можно открыть; он живёт рядом и пересобирается каждой компиляцией. Рефлексия даёт другой угол — пройтись по полям класса в рантайме, — но это перебор во время исполнения, дающий объекты Field, а не тип на этапе компиляции; результатом рефлексии нельзя аннотировать переменную.

Разрыв тот же, что в главе про условные типы: «Java так просто не может». Там, где TS пишет { [K in keyof T]?: T[K] } — выражение в системе типов, проверяемое и стираемое, не оставляющее ни одного лишнего файла, — Java подключает шаг сборки, который порождает реальный код, который потом тоже нужно компилировать и держать в синхроне. TS выводит производный тип; Java генерирует производный класс. Сопоставленный тип — это кодген, перенесённый внутрь системы типов и не оставляющий артефактов.

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

Механизм мощный, и ровно поэтому острых краёв у него много. Перечислим честно.

Гомоморфность тихо меняет поведение на массивах. Раз [K in keyof T] сохраняет структуру, на массивах и кортежах он сохраняет её слишком хорошо. Partial<string[]> — это не объект с необязательными числовыми ключами { 0?: string; 1?: string; … }, как можно было бы ждать. Гомоморфное отображение распознаёт массив и распределяется по нему как по массиву, а не по объекту с числовыми ключами:

type P = Partial<readonly string[]>; // readonly (string | undefined)[], а не объект с "0"?, "1"?

Результат остаётся массивом — тем же readonly-массивом, только тип элемента стал string | undefined. Методы (push, length) при этом необязательными не становятся: модификатор ? уходит на элементы, а не на члены массива. Стоит переписать отображение через as или иначе сбить гомоморфность — и тот же Partial перестанет распознавать массив, начнёт разбирать его на ключи и выдаст совсем другую форму. Поведение зависит от формы левой части, и зависимость эта не написана нигде в коде явно.

Фильтрация через as ... never тёмная. Удаление ключа отображением в never работает, но читается как ребус: ничто в синтаксисе не говорит «здесь свойство исчезает». Тот, кто видит as T[K] extends string ? K : never впервые, потратит минуту, чтобы понять, что половина полей просто испарится. Это идиома, которую надо знать, — вывести её из первых принципов с ходу нельзя.

Required не снимает | undefined из значения. -? убирает необязательность ключа, но не трогает тип значения. Если поле объявлено как note?: string, в нём на самом деле string | undefined, и -? уберёт ?, а undefined в типе значения может остаться:

type T = Required<{ note?: string }>;        // { note: string } — здесь чисто
type U = Required<{ note: string | undefined }>; // { note: string | undefined } — undefined остался!

Во втором случае ключ и так был обязателен, снимать нечего, а явный | undefined в значении -? не касается. «Сделал Required — значит, undefined исчез» — ложная интуиция, и она кусается.

Добавить новые ключи нелегко. Сопоставленный тип преобразует существующие ключи, но не умеет дописать к результату пару полей, которых в исходнике не было. Для этого результат отображения пересекают с обычным типом:

type WithMeta<T> = { [K in keyof T]: T[K] } & { updatedAt: number };

Само отображение замкнуто на keyof T; всё новое добавляется снаружи, через &. Неочевидно для того, кто ждёт, что внутри фигурных скобок можно дописать лишнюю строчку.

Синтаксис модификаторов неинтуитивен. +?, -?, -readonly — это не то, что угадывается. Знак стоит перед модификатором, а не после ключа; - снимает, голый модификатор добавляет, + существует и почти не нужен. Прочитать -readonly как «убрать readonly» можно, только зная правило заранее.

Глубокая связка нечитаема, и у неё есть потолок. Сопоставленный плюс условный плюс шаблонный литеральный тип в одном выражении — например, рекурсивный DeepReadonly, переименовывающий вложенные ключи, — превращается в стену символов, которую не разбирает никто, включая автора неделю спустя. А рекурсивные сопоставленные типы упираются в пределы глубины и в производительность компилятора: на больших или самоссылающихся формах проверка ощутимо тормозит, а на слишком глубоких — обрывается с ошибкой про предельную глубину инстанцирования (глава 22).

Ошибки непрозрачны. Когда сопоставленный тип на чём-то спотыкается, компилятор показывает не «вот это поле не сошлось», а развёрнутую форму со всеми подставленными ключами и условиями — простыню, в которой настоящую причину приходится выкапывать. Чем сложнее отображение, тем хуже диагностика.

Ни одно из этого не делает сопоставленные типы плохими. Но каждое — место, где уверенность «я понял механизм» опережает реальность, и где платят за выразительность читаемостью и предсказуемостью.

Нужна та же форма, но каждое поле — X: не пиши вариант руками

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

Рефлекс «вторая форма похожа на первую, перепишу её руками с правкой» — тот же рефлекс кодгена из мира Java, только без генератора, своими пальцами. Он даёт две формы, связанные лишь вашей бдительностью, и расхождение между ними — вопрос времени. Сопоставленный тип убирает вторую форму вовсе: остаётся одна, исходная, и правило, как из неё получить нужную. Изменили исходник — производное пересчиталось само.

Думать об этом стоит по слоям. Сопоставленный тип — это перебор ключей с пересборкой формы: левая часть выбирает, по каким ключам идти, правая — во что превратить каждое значение. Изменяемость и опциональность переключаются модификаторами декларативно, по всем полям разом, — ?, -?, readonly, -readonly. Имена ключей переименовываются и фильтруются через as, вплоть до удаления полей отображением в never, — но это та область, где надо вовремя остановиться, не дойдя до нечитаемости. Где возможно, предпочитай гомоморфную форму keyof T: она сохраняет структуру исходника, а не пересобирает её с потерями. А когда полю нужна индивидуальная логика, ставь в правую часть условный тип с infer — обход снаружи, ветвление внутри.

И главное: из этих пяти приёмов собрана вся Partial/Required/Readonly/Pick/Record секция стандартной библиотеки. Утилитные типы (глава 23) — не встроенные примитивы, а сопоставленные типы в одну-две строки. Кто владеет механизмом, тот не заучивает их список, а читает их как обычный код и при нужде пишет свои.

Итог

Сопоставленный тип механически перешивает форму по её ключам, преобразуя каждое свойство: одна исходная форма и правило вместо двух копий, синхронизируемых руками. Кто им владеет, тот выводит производные типы из исходных — там, где другой язык зовёт кодогенератор, а неосторожный программист переписывает форму заново и потом гоняется за расхождениями.

← Глава 19. Условные типы и infer Содержание Глава 21. Шаблонные литеральные типы →