Глава 23. Утилитные типы изнутри
Глава 23. Утилитные типы изнутри
Магия, оказавшаяся однострочниками
Ты пишешь это каждый день и почти не задумываешься:
type Order = {
id: string;
total: number;
status: "created" | "paid" | "shipped" | "delivered" | "cancelled";
items: { sku: string; qty: number }[];
};
// форма для черновика заказа: всё необязательно
type OrderDraft = Partial<Order>;
// форма для ответа API: только id и статус
type OrderSummary = Pick<Order, "id" | "status">;
// справочник заказов по их id
type OrderIndex = Record<OrderId, Order>;
// тип того, что вернёт функция расчёта
type Total = ReturnType<typeof computeTotal>;
Partial, Pick, Record, ReturnType — они в каждом проекте, и ощущаются как часть компилятора. Что-то вроде instanceof или typeof: операторы, встроенные в язык, со своей внутренней механикой, которую написали авторы TypeScript и в которую заглядывать не положено. Ты их зовёшь — они работают. Откуда берётся Partial, где живёт его код, можно ли написать такой же — вопросы, которые обычно не задают, потому что ответ кажется заведомо «это магия компилятора, тебе туда не надо».
Тот, кто пришёл из Java, узнаёт здесь свою старую боль с обратной стороны. Чтобы у тебя были «заказ для создания», «заказ для обновления» и «заказ в ответе API», ты заводил три отдельных класса — CreateOrderDTO, UpdateOrderDTO, OrderResponse, — каждый руками, каждый со своим набором полей, и каждый рассыпался в рассинхрон, стоило добавить поле в исходную сущность. Либо ты подключал генератор кода или библиотеку с аннотациями, чтобы эти классы плодились автоматически. Partial, Pick, Omit — ровно то, что схлопывает всю эту параллельную бухгалтерию в одну строку. И тем сильнее ощущение, что под строкой спрятан целый механизм.
Под строкой нет механизма. Под строкой — однострочник:
type Partial<T> = { [K in keyof T]?: T[K] };
Это всё определение Partial. Не вызов в недра компилятора, не нативная функция — обычный тип на обычном TypeScript, который ты к этой главе уже умеешь читать слева направо. [K in keyof T] — пробежать по всем ключам T; ? — сделать каждый необязательным; T[K] — оставить исходный тип значения. Сопоставленный тип (mapped type, тип, порождающий новый объектный тип перебором ключей исходного) из главы 20 и оператор keyof из главы 18 — и больше ничего. Ты держал это в руках на протяжении всей части IV, просто под другим именем.
Тогда возникает настоящий вопрос главы. Если Partial — это три строки на знакомых примитивах, то что такое весь набор утилит? Где он лежит, из чего собран, и — раз уж он собран из того, что ты уже знаешь, — можешь ли ты прочитать любую утилиту насквозь и построить ту, которой в наборе не оказалось?
Это не магия, а маленькая библиотека на тех же примитивах
Утилитные типы не встроены в синтаксис языка. Они объявлены — текстом, на TypeScript — в файлах деклараций, которые поставляются вместе с компилятором. Имя файла — lib.es5.d.ts; рядом лежат lib.es2015.d.ts, lib.dom.d.ts и прочие, по одному на уровень стандарта. Открой любой — и увидишь те же type и interface, что пишешь сам. Разница лишь в том, что эти объявления подгружаются автоматически, поэтому Partial доступен без импорта, как будто он часть грамматики. Он не часть грамматики. Он часть стандартной библиотеки типов.
Вот несколько определений оттуда, дословно — ты прочитаешь каждое:
type Partial<T> = { [K in keyof T]?: T[K] };
type Required<T> = { [K in keyof T]-?: T[K] };
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
type Record<K extends keyof any, T> = { [P in K]: T };
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
type NonNullable<T> = T & {};
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
Ни одной новой конструкции. Сопоставленные типы с модификаторами ?, -?, readonly — глава 20. keyof и ограничение extends keyof T — глава 18. Условные типы T extends U ? X : Y — глава 19. Omit, собранный из Pick и Exclude через подстановку одного в другой, — композиция функций над типами из главы 17. Стандартная библиотека утилит — это не отдельный язык со своими правилами, а упражнение по части IV, выполненное авторами TypeScript за тебя и положенное в коробку.
Из честности — про границу. Несколько утилит всё-таки реализованы внутри компилятора и на чистом TypeScript невоспроизводимы. Это строковые intrinsic-типы (интринсик, тип, чьё вычисление вшито в компилятор): Uppercase, Lowercase, Capitalize, Uncapitalize.
type Shout = Uppercase<"paid">; // "PAID"
Их объявления в lib.es5.d.ts выглядят как обычные, но помечены ключевым словом intrinsic вместо тела — там, где у Partial стоит { [K in keyof T]?: T[K] }, у них стоит intrinsic:
type Uppercase<S extends string> = intrinsic;
Это значит: «тело тут не на TypeScript, его вычисляет сам компилятор». Поднять регистр строки на уровне типов средствами шаблонных литеральных типов (глава 21) нельзя — нет операции, которая переводит символ в верхний регистр; её пришлось зашить в движок. Таких интринсиков ровно четыре, все про регистр строк, и это весь список настоящей магии в наборе. Всё остальное — читаемый исходник.
Семейство сопоставленных
Разберём набор по тому примитиву, на котором каждая утилита держится, — так видно не отдельные рецепты, а три-четыре идеи, из которых собрано всё. Первое семейство стоит на сопоставленных типах.
Partial мы уже прочитали. Его зеркало — Required — отличается одним символом:
type Required<T> = { [K in keyof T]-?: T[K] };
Модификатор ? в сопоставленном типе можно не только ставить, но и снимать. -? означает «убрать необязательность с каждого поля». Применим к заказу, у которого часть полей необязательна:
type RawOrder = {
id: string;
total?: number; // могло не прийти
note?: string;
};
type FullOrder = Required<RawOrder>;
// { id: string; total: number; note: string } — оба поля стали обязательными
Readonly устроен так же, только трогает не необязательность, а изменяемость:
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type FrozenOrder = Readonly<Order>;
const o: FrozenOrder = getOrder();
o.total = 0; // Ошибка: Cannot assign to 'total' because it is a read-only property.
Три утилиты — три модификатора сопоставленного типа: добавить ?, снять ?, добавить readonly. Никакого общего механизма «утилит» под ними нет; есть механизм сопоставленных типов, а это просто три его применения.
Pick использует тот же перебор, но не по всем ключам T, а по заданному подмножеству:
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
Ограничение K extends keyof T — обещание, что K состоит только из реальных ключей T. Сопоставление [P in K] бежит уже по K, а не по keyof T, и для каждого выбранного ключа берёт исходный тип значения T[P]:
type OrderSummary = Pick<Order, "id" | "status">;
// { id: string; status: "created" | "paid" | "shipped" | "delivered" | "cancelled" }
type Bad = Pick<Order, "id" | "price">;
// Ошибка: Type '"id" | "price"' does not satisfy the constraint 'keyof Order'.
// Type '"price"' is not assignable to type 'keyof Order'.
Record — тоже сопоставленный тип, но другой природы. Предыдущие были гомоморфными (homomorphic, повторяющими структуру исходного объектного типа): они шли по keyof T и наследовали от T его модификаторы и форму. Record строит тип с нуля по произвольному объединению ключей:
type Record<K extends keyof any, T> = { [P in K]: T };
keyof any — это string | number | symbol, то есть всё, что вообще может быть ключом. Перебор [P in K] создаёт поле на каждый член объединения K, и каждому даёт один и тот же тип значения T:
type OrderIndex = Record<OrderId, Order>; // OrderId — это string
// { [x: string]: Order }
type StatusLabels = Record<Order["status"], string>;
// { created: string; paid: string; shipped: string; delivered: string; cancelled: string }
Во втором случае ключи — конечное объединение литералов, и Record разворачивает его в объект с ровно этими пятью полями. Разница между гомоморфным Pick и негомоморфным Record ещё аукнется в разделе про острые края: Record<string, V> будет утверждать, что по любому строковому ключу лежит V, хотя в рантайме там может не лежать ничего.
Семейство условных с распределением
Второе семейство стоит не на переборе ключей объекта, а на условных типах и их распределении (distribution, поэлементное применение условного типа к каждому члену объединения) из главы 19.
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
Прочитать Exclude<T, U> буквально — «если T присваиваемо U, выкинь его (never), иначе оставь». Сама по себе эта запись выглядела бы как проверка одного типа. Но когда T — объединение, условный тип распределяется: применяется к каждому члену по отдельности, а результаты собираются обратно в объединение. Именно распределение превращает однострочник в операцию над множеством:
type Status = Order["status"];
// "created" | "paid" | "shipped" | "delivered" | "cancelled"
type Active = Exclude<Status, "cancelled" | "delivered">;
// "created" | "paid" | "shipped"
Распределение прошло по пяти литералам: "created" extends ... ? never : "created" и так далее. Три литерала условие не прошли — остались собой; два прошли — стали never; а never в объединении исчезает (X | never это X). На выходе — объединение из трёх. Extract — то же зеркало с переставленными ветвями: оставляет пересечение вместо разности.
NonNullable решает ту же задачу — выкинуть null и undefined, — но другим приёмом. Когда-то он и был частным случаем Exclude (T extends null | undefined ? never : T); в актуальной библиотеке его переписали короче, через пересечение:
type NonNullable<T> = T & {};
type MaybeTotal = number | null | undefined;
type DefinitelyTotal = NonNullable<MaybeTotal>; // number
Пересечение с пустым объектным типом {} отсекает именно null и undefined: оба не присваиваемы {}, и null & {}, undefined & {} схлопываются в never, а never в объединении исчезает. Распределения тут уже нет — но результат тот же, что давал прежний условный вариант.
Теперь — место, где два семейства встречаются. Omit не определён ни через сопоставление, ни через распределение напрямую. Он собран из готовых утилит:
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
Читается изнутри наружу. keyof T — все ключи исходного типа. Exclude<keyof T, K> — выбросить из них те, что перечислены в K, распределением по объединению ключей. Pick<T, ...> — построить объект из оставшихся ключей сопоставленным типом. Omit — это композиция Pick ∘ Exclude, функция над типами, склеенная из двух других функций над типами, ровно как обычная функция склеивается из вызовов других функций:
type OrderWithoutItems = Omit<Order, "items">;
// { id: string; total: number; status: ... } — items вырезан
Здесь видно, чем на самом деле является стандартная библиотека типов. Это не плоский список независимых заклинаний, а маленькая библиотека с внутренними зависимостями: Omit зовёт Pick и Exclude, как функция зовёт функции. Поняв базовые примитивы и научившись их сочетать, ты получаешь не двадцать запомненных рецептов, а правило их сборки.
Семейство infer — и построй своё
Третье семейство достаёт типы оттуда, где они не лежат отдельным параметром, а спрятаны внутри другого типа, — и достаёт их через infer (вывод, захват подтипа из образца внутри условного типа) из главы 19.
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : any;
ReturnType сопоставляет T с образцом «функция, возвращающая нечто», и это «нечто» захватывает в переменную R через infer. Если сопоставление удалось — отдаёт R, тип возврата:
function computeTotal(items: { qty: number; price: number }[]): number {
return items.reduce((s, i) => s + i.qty * i.price, 0);
}
type Total = ReturnType<typeof computeTotal>; // number
Parameters устроен симметрично, только infer ставится в позицию аргументов и захватывает их кортежем:
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
type Args = Parameters<typeof computeTotal>;
// [items: { qty: number; price: number }[]]
Awaited добавляет к infer рекурсию из главы 22 — он разворачивает вложенные промисы до конца, ссылаясь на самого себя:
type Awaited<T> =
T extends Promise<infer Inner> ? Awaited<Inner> : T;
type R = Awaited<Promise<Promise<Order>>>; // Order, а не Promise<Order>
(Настоящее определение в lib.es5.d.ts сложнее — оно сопоставляет не сам Promise, а объект с методом then, аккуратно обходя thenable-объекты и null, — но несущая идея именно эта: захватить содержимое промиса через infer и рекурсивно развернуть.)
Дойдя до этого места, ты больше не потребитель набора. Ты видел все три примитива, на которых он держится, и недостающую утилиту построишь сам. В стандартной библиотеке нет рекурсивного Partial — штатный поверхностен и до вложенных объектов не доходит. Напишем:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
type OrderPatch = DeepPartial<Order>;
// items стал (DeepPartial<{ sku: string; qty: number }>)[] | undefined,
// то есть { sku?: string; qty?: number }[] | undefined — необязательность пробила вглубь
Сопоставленный тип, который для каждого поля проверяет условным типом, объект ли там, и если да — рекурсивно зовёт сам себя. Три примитива из трёх семейств в одном определении.
Нет в наборе и Mutable — обратного к Readonly. Модификатор снимается тем же минусом, что и у Required:
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Editable = Mutable<Readonly<Order>>; // снова обычный изменяемый Order
А вот случай, где сходятся сопоставленный тип, переименование ключей и шаблонные литеральные типы из главы 21, — генератор сеттеров из формы объекта:
type Setters<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};
type OrderSetters = Setters<Order>;
// {
// setId: (value: string) => void;
// setTotal: (value: number) => void;
// setStatus: (value: "created" | "paid" | ...) => void;
// setItems: (value: { sku: string; qty: number }[]) => void;
// }
Клауза as в сопоставленном типе переписывает каждый ключ: шаблонный литерал склеивает "set" с именем ключа, поднятым в регистр интринсиком Capitalize. string & K нужен потому, что keyof T формально может включать number | symbol, а в шаблон вставляется только строковая часть. Тип значения при этом превращается в сеттер-функцию. Ни одной строчки магии — четыре примитива части IV, сложенные в одну форму.
Отсюда же — практический навык, который останется с тобой. Раз все утилиты лежат текстом в lib.*.d.ts, их можно читать напрямую. В редакторе наведись на Partial, нажми «перейти к определению» — и окажешься в файле деклараций, перед тем самым однострочником. Это и есть документация, точнее любой пересказанной: исходник, который никогда не врёт о том, что утилита делает.
Глазами джависта
В стандартной библиотеке Java нет ни
Partial, ниPick, ниReturnType— и не потому, что их забыли добавить, а потому, что в языке нет места, куда их положить. «Orderс частью полей», «Orderтолько сidиstatus», «тип того, что вернул метод» — это не выражения, которые можно вычислить о типе. Это всё — отдельные классы, которые ты пишешь руками.Поэтому в Java-проекте заводят
CreateOrderDTO,UpdateOrderDTO,OrderResponseкак параллельные классы рядом сOrder, и каждый живёт своей жизнью: добавил поле вOrder— пройдись по трём DTO и не забудь ни одного. Кто устал делать это руками, подключает генератор кода или библиотеку с аннотациями (MapStruct, Lombok), и тогда классы плодятся на этапе сборки отдельным инструментом. Либо лезет в рефлексию — единственный способ хоть что-то узнать о форме типа во время работы программы, и то ценой потери всякой статической проверки.Разница не в богатстве библиотеки, а в том, что библиотека вообще умеет описывать. Стандартная библиотека Java — это значения времени исполнения: списки, карты, потоки, объекты, которыми оперируют в рантайме. Категории «функция, принимающая тип и возвращающая тип» там нет как класса операций. В TypeScript она есть, потому что типы — сам по себе язык вычислений (часть IV), и стандартная библиотека этого языка естественно содержит функции над типами.
Pick,Omit,Partialсхлопывают в одну строку ровно ту параллельную бухгалтерию DTO-классов, которую джавист вёл вручную или генератором, — не потому что TypeScript усерднее, а потому что у него есть уровень, на котором эту бухгалтерию можно выразить как вычисление, а не как набор написанных файлов.
Бородавки утилит, теперь объяснённые
Раз утилиты — обычный код на знакомых примитивах, у них и недостатки обычные: не дефекты компилятора, а прямые следствия того, из чего они собраны. Зная сборку, ты предскажешь каждую бородавку, а не наткнёшься на неё.
Omit не проверяет ключи. Сравни ограничения у Pick и Omit:
type Pick<T, K extends keyof T> = { [P in K]: T[P] }; // K ⊆ keyof T
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>; // K — любой ключ
Pick требует K extends keyof T и ругается на чужой ключ. Omit ослаблен до keyof any, поэтому имя поля, которого в типе нет, проходит молча:
type A = Omit<Order, "items">; // ок, items вырезан
type B = Omit<Order, "px">; // ошибки НЕТ; px не существует в Order, и Omit это проглотил
// B === Order: вычитать было нечего, но компилятор промолчал
Это не каприз — это следствие определения. Exclude<keyof T, "px"> спокойно вычитает несуществующий ключ из множества (вычесть отсутствующее — пустая операция), и Pick строит тип из всех исходных ключей. Опечатка в Omit не ловится; переименуй поле в Order — и Omit<Order, "oldName"> тихо станет no-op вместо ошибки.
Partial и Readonly поверхностны. Сопоставленный тип проходит по верхним ключам — и останавливается:
type P = Partial<Order>;
// items стал ({ sku: string; qty: number }[]) | undefined,
// но ВНУТРИ массива sku и qty остались обязательными
const draft: P = { items: [{ sku: "A" }] };
// Ошибка: Property 'qty' is missing — Partial не дошёл до вложенного объекта
Лекарство — рекурсивный DeepPartial из предыдущего раздела. Штатные утилиты рекурсии не содержат сознательно: глубокий обход дороже и не всегда нужен.
Required не снимает явный | undefined. Модификатор -? убирает необязательность поля, но не вычищает undefined, вписанный в тип значения руками:
type T1 = { total?: number }; // необязательное
type R1 = Required<T1>; // { total: number } — чисто
type T2 = { total: number | undefined }; // обязательное, но допускает undefined
type R2 = Required<T2>; // { total: number | undefined } — undefined остался
? и | undefined — близкие, но не тождественные вещи (глава 10), и -? трогает только первое.
Record<string, V> врёт о наличии ключа. Тип утверждает, что по любому строковому ключу лежит V, — но в рантайме объект может быть пуст:
const prices: Record<string, number> = { "A-1": 500 };
const p = prices["A-2"]; // тип: number, значение: undefined
console.log(p.toFixed(2)); // компилируется; рантайм: Cannot read properties of undefined
Это та же поверхностная нечестность индексных сигнатур из главы 12: Record с открытым строковым ключом — синоним { [x: string]: V }, и компилятор не отслеживает, какие ключи реально присутствуют. (Флаг noUncheckedIndexedAccess добавляет к результату | undefined и закрывает дыру — ценой undefined на каждом обращении по ключу.)
ReturnType на перегруженной функции берёт последнюю сигнатуру. Перегрузки (глава 13) в выводе через infer сопоставляются не все — компилятор хватает последнюю:
function parse(x: string): number;
function parse(x: number): string;
function parse(x: any): any { return x; }
type R = ReturnType<typeof parse>; // string — взята только вторая перегрузка
Это не выбор «правильной» сигнатуры, а механика сопоставления: infer цепляется за последнюю в списке.
Зависящие от распределения ломаются, если их обернуть. Распределение Exclude, Extract, NonNullable работает только когда проверяемый тип стоит голым параметром. Оборачивание в кортеж [T] глушит распределение:
type Distributed<T> = T extends "cancelled" ? never : T; // распределяется
type Guarded<T> = [T] extends ["cancelled"] ? never : T; // НЕ распределяется
type A = Distributed<"paid" | "cancelled">; // "paid"
type B = Guarded<"paid" | "cancelled">; // "paid" | "cancelled" целиком, условие проверено разом
Иногда [T] ставят намеренно — чтобы запретить распределение. Но если забыть, что Exclude распределяется, и случайно обернуть аргумент, результат тихо поменяется.
Интринсики не воспроизвести. Uppercase и три его соседа реализованы внутри компилятора; на чистом TypeScript аналога не написать. Своя «утилита поднятия регистра» средствами шаблонных литералов невозможна — упрёшься в отсутствие операции над символом.
Цепочки вывода становятся нечитаемыми. Утилиты композируются, и ничто не мешает вложить их одна в другую на пять уровней:
type Hmm = Partial<Record<keyof Omit<Order, "items">, ReturnType<typeof computeTotal>>>;
Это валидно и вычислимо, но человек, читающий тип, разбирает его дольше, чем написал бы явный эквивалент. Бородавка тут не в компиляторе, а в дисциплине — и о ней разговор в следующем разделе и подробно в главе 30.
Выводи, а не дублируй; читай исходник; знай бородавку изнутри
Что должно перещёлкнуть после этой главы — три связанных сдвига.
Первый: тянись к производному типу, а не к параллельному определению. Старый рефлекс — на каждую форму завести отдельный тип руками: OrderForCreate, OrderForUpdate, OrderResponse, и держать их в синхроне усилием воли. Новый рефлекс — выводить эти формы из одной исходной через утилиты: Omit<Order, "id"> для создания, Partial<Order> для обновления, Pick<Order, "id" | "status"> для ответа. Источник истины один; производные пересчитываются сами, стоит измениться исходному. Это та же инверсия, что отличает вывод от объявления во всей книге: не описывать форму повторно, а вычислять её из уже описанной.
Второй: утилиты — читаемый исходник, а не закрытая магия. Раз Partial — три строки в lib.es5.d.ts, перестань относиться к набору как к чёрному ящику. Не помнишь, что именно делает Extract, — перейди к определению и прочитай однострочник вместо того, чтобы гадать или искать пересказ. И когда штатной утилиты не хватает, не обходи нехватку костылём: недостающую ты теперь пишешь сам из тех же примитивов, как написал DeepPartial, Mutable и Setters.
Третий: знай бородавку каждой изнутри. Omit не проверяет ключи, Partial поверхностен, Record врёт о наличии ключа — и теперь это не сюрпризы, на которые наткнёшься в проде, а предсказуемые свойства, выводимые из определения. Кто прочитал исходник, тот заранее знает, где утилита смолчит там, где хотелось бы ошибки.
И сразу — ограничитель, чтобы новый инструмент не превратился в новую болезнь. Производный тип хорош, пока его читают легче, чем писали бы руками. Пятиэтажная вложенность Partial<Record<keyof Omit<...>, ReturnType<...>>> ловкá, но именованный явный тип на её месте честнее: ясность важнее ловкости. Выводить — не значит выводить всё; это значит выводить там, где вывод яснее дубля, и останавливаться там, где дубль яснее вывода. Где проходит эта граница — вопрос вкуса и читаемости, и ему отведена отдельная глава в части VI (глава 30).
Итог
Стандартные утилиты — не встроенная в компилятор магия, а маленькая библиотека из нескольких строк на сопоставленных, условных типах и infer: ты читаешь их исходник, достраиваешь недостающее и знаешь каждую бородавку изнутри. На этом часть IV собрана целиком — от дженериков-функций до стандартной библиотеки функций над типами; кто видит в Partial композицию знакомых примитивов, а не заклинание, тот владеет типовым языком, а не благоговеет перед ним.