Отображённые типы: обход ключей типа для построения нового
Отображённый тип `{ [K in keyof T]: ... }` обходит ключи T, преобразуя значения, с модификаторами `?`/`-?` и `readonly`/`-readonly`. Тонкость — гомоморфность: отображение по `keyof T` сохраняет модификаторы и массивы/кортежи, а по отдельному объединению — нет.
Вы передаёте библиотеке объект конфигурации, и она принимает Partial<Config> — каждое поле вдруг опционально, хотя никто не писал второй интерфейс. Наводитесь на Partial и находите { [P in keyof T]?: T[P] }. Одна строка породила целого опционального двойника вашего типа. Partial, Required, Readonly, Pick, Record — почти каждый служебный тип в lib.es5.d.ts это отображённый тип: цикл по ключам, который пересобирает объект член за членом.
В этом уроке вы напишете каждый из этих служебных типов с нуля и разберёте негласное правило гомоморфности, которое определяет, сохранит ли ваш отображённый тип массивы — или молча превратит их в обычные объекты.
Обход ключей через K in keyof T
Спросите себя: откуда Partial<Config> знает, какие поля сделать опциональными, не перечисляя их? Потому что отображённые типы не знают ключей заранее — они вычисляют их при инстанцировании. В этом вся сила: добавьте поле в Config — и оно автоматически станет опциональным в Partial<Config> без единой дополнительной строки.
Отображённый тип выглядит как индексная сигнатура, но in делает его циклом: для каждого ключа K в объединении keyof T выдаётся свойство. Внутри T[K] — исходный тип значения по этому ключу.
type Stringify<T> = { [K in keyof T]: string };
type Config = { port: number; host: string; tls: boolean };
type S = Stringify<Config>;
// ^? { port: string; host: string; tls: string }keyof Config — это "port" | "host" | "tls"; отображённый тип обходит это объединение и ставит каждому значению string. Чтобы сохранить исходный тип значения, сошлитесь на T[K]:
type Clone<T> = { [K in keyof T]: T[K] };
type C = Clone<Config>;
// ^? { port: number; host: string; tls: boolean }Модификаторы: добавить или снять ? и readonly
Отображённый тип может прикрепить или убрать два модификатора свойства. ? делает опциональным, -? убирает опциональность; readonly добавляет его, -readonly убирает. Так строятся стандартные утилиты — вот они с нуля:
type MyPartial<T> = { [K in keyof T]?: T[K] }; // добавить ?
type MyRequired<T> = { [K in keyof T]-?: T[K] }; // снять ?
type MyReadonly<T> = { readonly [K in keyof T]: T[K] }; // добавить readonly
type MyMutable<T> = { -readonly [K in keyof T]: T[K] }; // снять readonly
type Src = { a?: number; readonly b: string };
type P = MyPartial<Src>; // ^? { a?: number; readonly b?: string }
type R = MyRequired<Src>; // ^? { a: number; readonly b: string }
type M = MyMutable<Src>; // ^? { a?: number; b: string }Заметьте: MyRequired<Src> превращает a? в обязательное a: number (? снят), но оставляет readonly b readonly — -? касается только опциональности. MyMutable снимает readonly с b, но оставляет a опциональным. Модификаторы ортогональны.
Гомоморфные против негомоморфных — тонкая, ключевая разница
Когда отображённый тип написан как { [K in keyof T]: ... } — обходит напрямую keyof T одного параметра типа — он гомоморфен. Гомоморфные отображённые типы делают две тихие, но важные вещи: сохраняют исходные модификаторы (поэтому Clone выше оставил readonly b) и сохраняют природу массива и кортежа:
type Clone<T> = { [K in keyof T]: T[K] }; // гомоморфный
type CA = Clone<number[]>; // ^? number[] — всё ещё массив
type CT = Clone<[1, 2]>; // ^? [1, 2] — всё ещё кортеж
type CR = Clone<readonly string[]>; // ^? readonly string[] — readonly сохранёнТеперь сравните отображённый тип, который обходит объединение ключей, полученное отдельным параметром — негомоморфный:
type FromKeys<K extends PropertyKey, V> = { [P in K]: V };
type Obj = FromKeys<"a" | "b", number>;
// ^? { a: number; b: number } — нормально для ключей объекта
type Broken = FromKeys<keyof number[], unknown>;
// ^? обычный объект со строковыми ключами number[] ("length", "push", ...) — НЕ массивFromKeys отображает по голому объединению ключей K, а не по keyof T, поэтому у TypeScript нет исходного контейнера для сохранения — результат всегда обычный объект. Именно поэтому Record<K, V> = { [P in K]: V } даёт объекты, а Partial/Readonly (написанные по keyof T) точно сохраняют массивы и кортежи.
▸Почему это работает
TypeScript определяет гомоморфность синтаксически: ограничение отображённого типа должно быть в точности keyof T для одного параметра типа T (или сам T, когда T ограничен типом ключа). Увидев эту форму, он держит связь с источником T, чтобы копировать модификаторы T по ключам и особо обрабатывать массивы/кортежи — отображая каждый тип элемента и пересобирая тот же контейнер. Потеряйте эту точную форму keyof T — пропустив ключи через другой параметр или ремаппинг (следующий урок) — и связь исчезает, модификаторы теряются, а массивы схлопываются в обычные объекты.
Цена: отображаемые типы масштабируются с шириной объекта
Отображаемый тип инстанцирует по одному свойству на ключ, поэтому его стоимость линейна по числу ключей — обычно пренебрежима. Взрыв даёт композиция: Partial<DeepReadonly<Pick<T, ...>>> над сгенерированным типом из 200 полей заново материализует целый объект на каждом слое, а сложенное над объединением таких типов число свойств перемножается. Жёсткий потолок — те же лимиты объединения/объекта, что и везде: отображаемый тип, чьё выражение значения дистрибутирует условие над большим объединением, может упереться в стену Expression produces a union type that is too complex to represent на 100 000 членах.
Senior-критерий: отображаемый тип окупается, когда преобразование должно применяться единообразно к неизвестному или эволюционирующему набору ключей — Partial<Config> остаётся верным, когда кто-то добавляет поле. Это оверинжиниринг, когда объект мал и фиксирован: написать опциональный двойник руками — две лишние строки, которые читатель понимает мгновенно, тогда как отображаемый тип заставляет его мысленно его развернуть. А правила модификаторов/гомоморфизма — реальный налог на сопровождение: следующий инженер должен знать, что прогон ключей через as или отдельный параметр молча сбрасывает readonly и схлопывает массивы (баги из этого урока). Когда форма всё равно генерируется из схемы (Prisma, GraphQL, OpenAPI), предпочтите плоский interface из кодогенерации обёртыванию его в три слоя отображаемых утилит; сгенерированный интерфейс можно навести и сравнить в diff, глубокая отображаемая башня — нет.
▸lesson.inset.warning
Налог на ширину измерим. На реальном проекте сплошное обёртывание всей поверхности API в рекурсивный DeepPartial/DeepReadonly над сгенерированными типами — классический способ добавить 3–10с к Check time (видно в tsc --extendedDiagnostics как скачок Instantiation count) и заставить наведение на глубоко отображённый тип в редакторе тормозить секундами, потому что IntelliSense должен инстанцировать весь результат отображения, чтобы отрисовать подсказку. Лечение обычно не более хитрый тип, а сужение области — отображайте только те ключи, что действительно преобразуете, или пусть кодогенерация выдаст плоский тип один раз вместо пересчёта на каждой проверке.
Дано type Clone<T> = { [K in keyof T]: T[K] }. Чему равно Clone<readonly [1, 2]>?
Упорядочьте, как TypeScript вычисляет MyPartial<{ a: number; readonly b: string }>, где MyPartial<T> = { [K in keyof T]?: T[K] }:
- 1 Распознаёт, что ограничение это keyof T, значит это гомоморфный отображённый тип, связанный с источником
- 2 Вычисляет keyof T как объединение ключей 'a' | 'b'
- 3 Для каждого ключа копирует модификаторы источника (readonly у b) и выдаёт тип значения T[K]
- 4 Применяет добавленный модификатор ? к каждому ключу, давая { a?: number; readonly b?: string }
- 01Что такое отображённый тип, что означает T[K] внутри него и как написать Stringify и Clone?
- 02Какие четыре модификатора отображения и что дают MyPartial/MyRequired/MyReadonly/MyMutable на { a?: number; readonly b: string }?
- 03Что делает отображённый тип гомоморфным, что сохраняет гомоморфность и почему Record даёт обычный объект?
Отображённые типы дали вам цикл — обойти keyof T, преобразовать каждое значение и переключить ?/readonly — а гомоморфность это тихая гарантия, что цикл сохраняет модификаторы источника и форму контейнера. Дальше — ремаппинг ключей через as вскрывает сторону ключей этого цикла: вместо переиспользования каждого ключа без изменений вы вычисляете новый ключ на каждой итерации — переименовываете через get${Capitalize<K>} или вовсе отфильтровываете ключи, ремаппя их в never. Теперь, когда видите Partial<Config> в кодовой базе команды, вы точно знаете, какая строка за этим стоит — и почему добавление поля в конфиг никогда не ломает отображённый тип.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.