open atlas
↑ К треку
Система типов TypeScript вглубь TS · 05 · 03

Утилитные типы с нуля

Каждый стандартный утилитный тип — это однострочный mapped- или условный тип; переписав Partial, Pick, Omit, ReturnType, Awaited и компанию, вы демистифицируете их и поймёте, почему стандартный Omit не-дистрибутивен.

TS Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Вы берёте 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 : anyinfer в условии
Parameters<F>F extends (...a: infer P) => any ? P : neverinfer кортежа аргументов
InstanceType<C>C extends new (...a: any) => infer R ? R : anyinfer возврата конструктора

Семейство условных: вывести интересную часть

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. 1 DeepReadonly<{ a: { b: number } }>: не массив, объект -> отобразить каждый ключ
  2. 2 Ключ a: DeepReadonly<{ b: number }> -> снова объект -> отобразить каждый ключ
  3. 3 Ключ b: DeepReadonly<number> -> не массив, не объект -> базовый случай возвращает number
  4. 4 Внутреннее отображение даёт { readonly b: number }
  5. 5 Внешнее отображение даёт { readonly a: { readonly b: number } }
Вспомните перед уходом
  1. 01
    Восстановите Partial, Required, Readonly, Pick и Record с нуля и объясните синтаксис модификаторов.
  2. 02
    Как работают ReturnType, Parameters, InstanceType и Awaited и какой из них рекурсивный?
  3. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.