Утилитные типы с нуля
Каждый стандартный утилитный тип — это однострочный mapped- или условный тип; переписав Partial, Pick, Omit, ReturnType, Awaited и компанию, вы демистифицируете их и поймёте, почему стандартный Omit не-дистрибутивен.
Вы берёте Omit<Shape, "kind"> для дискриминированного объединения Circle | Square, и результат неверен: вместо «каждый вариант минус его kind» вы получаете объект только с ключами, общими для всех вариантов, — радиус и сторона исчезли. Вы смотрите на это, уверенные, что стандартная библиотека сломана. Это не так. Omit определён как Pick<T, Exclude<keyof T, K>>, а keyof (Circle | Square) — это только общие ключи. Как только вы напишете Omit сами, это перестаёт быть загадкой и становится фиксом в одну строку. Каждый утилитный тип в lib.es5.d.ts — это mapped- или условный тип, который можно прочитать, пересобрать и перерасти.
Семейство mapped-типов
Вы использовали Partial, Required и Readonly сотни раз — но написали бы их с нуля? Как только вы можете, стандартная библиотека перестаёт быть чёрным ящиком и превращается в набор блоков, которые можно прочитать, адаптировать и перерасти. Partial, Required и Readonly — mapped-типы с модификатором; Pick и Record выбирают набор ключей:
type MyPartial<T> = { [P in keyof T]?: T[P] }; // добавить ? к каждому ключу
type MyRequired<T> = { [P in keyof T]-?: T[P] }; // -? убирает опциональность
type MyReadonly<T> = { readonly [P in keyof T]: T[P] }; // добавить readonly
type MyPick<T, K extends keyof T> = { [P in K]: T[P] }; // отобразить подмножество ключей
type MyRecord<K extends keyof any, V> = { [P in K]: V }; // построить из ключей + значенияСинтаксис модификаторов — это вся суть: ? и readonly добавляют модификатор; -? и -readonly снимают его. keyof any — это string | number | symbol, легальное пространство ключей для Record.
| Утилита | Однострочное определение | Механизм |
|---|---|---|
Partial<T> | { [P in keyof T]?: T[P] } | mapped + опц. модификатор |
Required<T> | { [P in keyof T]-?: T[P] } | mapped + снять опц. |
Readonly<T> | { readonly [P in keyof T]: T[P] } | mapped + readonly |
Pick<T,K> | { [P in K]: T[P] } | отобразить подмножество ключей |
Record<K,V> | { [P in K]: V } | ключи -> одно значение |
Exclude<T,U> | T extends U ? never : T | дистрибутивный фильтр |
Extract<T,U> | T extends U ? T : never | дистрибутивное оставление |
NonNullable<T> | T & {} | пересечь, чтобы убрать null/undefined (TS 4.8+) |
Omit<T,K> | Pick<T, Exclude<keyof T, K>> | взять дополнение — НЕ-дистрибутивен |
ReturnType<F> | F extends (...a: any) => infer R ? R : any | infer в условии |
Parameters<F> | F extends (...a: infer P) => any ? P : never | infer кортежа аргументов |
InstanceType<C> | C extends new (...a: any) => infer R ? R : any | infer возврата конструктора |
Семейство условных: вывести интересную часть
ReturnType, Parameters, InstanceType и Awaited используют infer, чтобы вытащить кусок из структурной формы:
type MyReturnType<F> = F extends (...args: any) => infer R ? R : any;
type MyParameters<F> = F extends (...args: infer P) => any ? P : never;
type MyInstanceType<C> = C extends new (...args: any) => infer R ? R : any;
type R1 = MyReturnType<() => number>; // ^? type R1 = number
type P1 = MyParameters<(a: string, b: number) => void>; // ^? type P1 = [a: string, b: number]Awaited — рекурсивный: он разворачивает вложенные промисы и .then-аблы до итогового значения:
type MyAwaited<T> =
T extends null | undefined ? T :
T extends object & { then(onfulfilled: infer F, ...args: any): any }
? F extends (value: infer V, ...args: any) => any ? MyAwaited<V> : never
: T;
type A1 = MyAwaited<Promise<Promise<string>>>;
// ^? type A1 = string (рекурсия разворачивает оба слоя)▸Почему это работает
Почему NonNullable<T> определён как T & {}, а не T extends null | undefined ? never : T? Работают оба, но T & {} (TS 4.8+) сохраняет больше информации: {} — это «любое не-null, не-undefined значение», и пересечение с ним убирает ровно null и undefined, оставляя обобщённые и условные типы не-жадно вычисленными. Условная форма принудительно распределяет и может преждевременно схлопнуть ещё обобщённый T. Пересечение — это современное, не-дистрибутивное определение, которое поставляет стандартная библиотека.
Композиция: DeepPartial и DeepReadonly
Рекурсируйте mapped-тип во вложенные объекты с условным базовым случаем для не-объектов:
type DeepReadonly<T> = T extends (infer E)[]
? ReadonlyArray<DeepReadonly<E>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
type Config = { db: { host: string; pool: { max: number } } };
type DC = DeepPartial<Config>;
// ^? каждый уровень опционален: { db?: { host?: string; pool?: { max?: number } } }Базовый случай — «не объект — вернуть как есть»; рекурсивный случай заново отображает каждое свойство через то же глубокое преобразование.
Ловушка Omit — и DistributiveOmit
Вот сюрприз дискриминированного объединения из Hook. Omit<T, K> = Pick<T, Exclude<keyof T, K>>. Когда T — объединение, keyof T схлопывается до только тех ключей, что есть в каждом члене:
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Shape = Circle | Square;
type Bad = Omit<Shape, "kind">;
// ^? type Bad = {} (keyof Shape — это только "kind"; убрав его, не остаётся ничего)
// Принудительная дистрибуция, чтобы Omit выполнялся по членам:
type DistributiveOmit<T, K extends keyof any> =
T extends unknown ? Omit<T, K> : never;
type Good = DistributiveOmit<Shape, "kind">;
// ^? type Good = { radius: number } | { side: number }Omit намеренно не-дистрибутивен — он работает с T как с целым, поэтому keyof (Circle | Square) — это лишь "kind" (пересечение наборов ключей). Обёртка в условие с голым параметром (T extends unknown ? Omit<T, K> : never) принуждает дистрибуцию: теперь Omit выполняется раз на вариант, и вы получаете ожидаемый по-вариантный результат. Этот единственный паттерн — самая частая причина, по которой люди думают, что Omit сломан на объединениях.
Почему `Omit<Circle | Square, 'kind'>` (где у каждого свой уникальный лишний ключ) даёт `{}` вместо по-вариантного остатка?
Расставьте вычисление DeepReadonly<{ a: { b: number } }> от внешнего матча до самого глубокого листа.
- 1 DeepReadonly<{ a: { b: number } }>: не массив, объект -> отобразить каждый ключ
- 2 Ключ a: DeepReadonly<{ b: number }> -> снова объект -> отобразить каждый ключ
- 3 Ключ b: DeepReadonly<number> -> не массив, не объект -> базовый случай возвращает number
- 4 Внутреннее отображение даёт { readonly b: number }
- 5 Внешнее отображение даёт { readonly a: { readonly b: number } }
- 01Восстановите Partial, Required, Readonly, Pick и Record с нуля и объясните синтаксис модификаторов.
- 02Как работают ReturnType, Parameters, InstanceType и Awaited и какой из них рекурсивный?
- 03Объясните, почему стандартный Omit не-дистрибутивен, что ломается на дискриминированном объединении и точный фикс DistributiveOmit.
Стандартная библиотека — не магия: Partial/Required/Readonly/Pick/Record — это mapped-типы с модификатором; Exclude/Extract — дистрибутивные условия; ReturnType/Parameters/InstanceType выводят кусок вызываемого; NonNullable — это T & {}; Awaited рекурсирует, разворачивая промисы. Композируйте их рекурсивно для DeepPartial и DeepReadonly. Единственная ловушка для запоминания: Omit — это Pick<T, Exclude<keyof T, K>> и потому не-дистрибутивен — на объединении он держит лишь общие ключи, так что берите DistributiveOmit (T extends unknown ? Omit<T, K> : never), когда нужно по-вариантное поведение. Теперь, когда Omit вернёт пустой объект на дискриминированном объединении, вы сразу знаете почему — и однострочный фикс.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.