← Глава 22. Рекурсия и пределы Содержание Глава 24. Где компилятор лжёт →

Глава 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 композицию знакомых примитивов, а не заклинание, тот владеет типовым языком, а не благоговеет перед ним.

← Глава 22. Рекурсия и пределы Содержание Глава 24. Где компилятор лжёт →