Ремаппинг ключей через as: переименование и фильтрация ключей в отображённом типе
Ремаппинг ключей (`as`, TS 4.1) вычисляет новый ключ на каждой итерации. Переименовывайте шаблонными литералами и `Capitalize` или ремаппьте ключ в `never`, чтобы удалить — так фильтруют по типу значения. Ключи должны быть string|number|symbol, а `as` ломает гомоморфность.
Библиотека управления состоянием типизирует действия вашего стора из состояния: { name: string; age: number } становится { setName(v: string): void; setAge(v: number): void }. Без генерации кода, без декораторов — отображённый тип переименовал каждый ключ из name в setName и переформировал его значение в сеттер. Механизм — оговорка в четыре слова внутри отображённого типа: as, за которым следует вычисленный ключ. И тот же as может заставить ключ исчезнуть.
После этого урока вы сможете построить такое переименование, отфильтровать тип до полей-данных по значению — и точно объяснить, что вы теряете при as: гомоморфность уходит, readonly молча сбрасывается.
Переименование ключей через as и шаблонные литералы
Когда нужно, чтобы все ключи типа следовали соглашению об именовании — добавить префикс get, суффикс Changed, привести к верхнему регистру — можно написать каждый вручную. Или указать отображённому типу вычислять ключ, а не переиспользовать его.
С TS 4.1 отображённый тип может добавить as <выражение> после части in. Для каждого K свойство выдаётся под ключом, в который разрешается <выражение>, а не под самим K. В сочетании с шаблонными литеральными типами и встроенным Capitalize это переименовывает ключи механически:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type State = { name: string; age: number };
type G = Getters<State>;
// ^? { getName: () => string; getAge: () => number }Capitalize<"name"> — это "Name" (встроенный тип манипуляции строкой — урок про шаблонные литералы в юните 05 разбирает их полностью), поэтому шаблон `get${...}` даёт "getName". Пересечение string & K несущее: keyof T может включать ключи number и symbol, но Capitalize принимает только string, поэтому string & K сужает каждый ключ до его строковой части перед интерполяцией.
Фильтрация ключей: ремаппинг в never, чтобы удалить их
Вот приём, который делает ремаппинг мощным. Если вычисленный ключ — never, это свойство полностью опускается. Соедините это с условием на тип значения — и получите фильтрацию по значению, без списка ключей для Pick/Omit:
// Убрать каждое свойство, чьё значение — функция
type DataOnly<T> = {
[K in keyof T as T[K] extends (...a: any[]) => any ? never : K]: T[K];
};
type Model = { id: number; name: string; save(): void; load(): void };
type D = DataOnly<Model>;
// ^? { id: number; name: string } — save и load ремаппнуты в never, удаленыУсловие выполняется по ключам: ключи со значениями-функциями ремаппятся в never (и исчезают), остальные ремаппятся в самих себя (K) и выживают. Обратное — оставить только ключи, чьё значение совпадает — просто меняет ветки:
// Оставить только ключи, чьё значение — строка (Pick-по-типу-значения)
type StringKeysOnly<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type R = StringKeysOnly<{ a: string; b: number; c: string }>;
// ^? { a: string; c: string }▸Почему это работает
Почему never удаляет ключ, а не создаёт свойство с ключом never? Множество ключей отображённого типа — это объединение всех вычисленных ключей, а never — пустое объединение: оно ничего не добавляет в это объединение, поэтому для этой итерации свойство не порождается. Это тот же факт «never — пустое объединение» из урока про условные типы, теперь использованный как примитив удаления. Важно: значение T[K] удалённого ключа никогда не вычисляется в выход, поэтому это настоящее опущение, а не член со значением never.
Два ограничения: допустимые типы ключей и потерянная гомоморфность
Вычисленный ключ должен быть совместим с PropertyKey — то есть string | number | symbol. Ремаппьте в что-то иное (объект, булево) — и TypeScript выдаст ошибку. Поэтому переименования идут через шаблонные литералы (которые дают строковые ключи), а фильтры — через never (пустой ключ).
Ремаппинг также ломает гомоморфность (прошлый урок): как только вы добавляете as, отображённый тип перестаёт быть голой формой { [K in keyof T]: ... }, поэтому связь с источником разрывается — модификаторы вроде readonly/? больше не копируются автоматически, а массивы/кортежи не сохраняются как контейнеры. Если нужны и переименование, и сохранение модификаторов, вы заново применяете модификаторы явно.
Цена: карта с переименованием ключей — это долг по сопровождению
Переименование ключей — самый магический из трюков с отображаемыми типами, а у магии есть цена. Первая цена — незаметная поломка: поскольку as рвёт гомоморфизм, Getters<T> молча сбрасывает модификаторы readonly и ?, которые нёс источник. Коллега, позже пометивший поле readonly в типе состояния, видит, как это ограничение исчезает из сгенерированных сеттеров — реальный баг без ошибки. Вторая цена — навигируемость: getName не существует нигде в источнике, поэтому Find-References, grep и ментальная модель нового читателя его не находят. Сгенерированный ключ не отлаживается текстовым поиском.
Senior-критерий: переименование уместно, когда правило переименования действительно единообразно, а исходный набор открыт — вывод интерфейса обработчиков событий из типа пропсов, где добавление пропса должно автоматически добавлять обработчик. Это неверный инструмент в момент, когда преобразование ключа кодирует бизнес-смысл, который читатель должен видеть, или когда у источника лишь горстка фиксированных полей. Для фильтрации по значению конкретно — взвесьте против рантайм-проверки: если цель — действовать над полями-данными в рантайме, обычный Object.entries(...).filter(...) читаем и тестируем, тогда как DataOnly<T> лишь сдвигает различение на этап компиляции и в рантайме не даёт ничего. А когда внешнему потребителю (SDK, публичному API) нужны эти переименованные формы, шаг кодогенерации, выписывающий interface StoreActions { setName(v: string): void } как настоящий исходник, лучше переименованного типа, который потребитель не может ни сгрепать, ни чисто навести, ни расширить — сгенерированный .d.ts — это артефакт; башня as — загадка.
▸lesson.inset.warning
Переименование также усиливает риск взрыва шаблонных литералов, с которым вы встретитесь в следующем уроке. Переименование вроде as `${Uppercase<K>}_${Uppercase<P>}` над двумя объединениями ключей строит декартово произведение строковых литералов: два набора по 50 членов дают объединение в 2500 членов за один шаг, а третья ось толкает вас к потолку в 100 000 членов и Expression produces a union type that is too complex to represent. Следите за Instantiation count под tsc --extendedDiagnostics; переименование, перемножающее наборы ключей, — быстрый путь от отзывчивого редактора к многосекундным наведениям. Держите входные наборы ключей переименования малыми или генерируйте имена заранее.
Чему равно DataOnly<{ id: number; save(): void }>, где DataOnly<T> = { [K in keyof T as T[K] extends (...a: any[]) => any ? never : K]: T[K] }?
Упорядочьте, как TypeScript разрешает Getters<{ name: string }>, где Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] }:
- 1 Обходит keyof T; единственный ключ — 'name'
- 2 Сужает ключ через string & K до его строковой части, 'name'
- 3 Вычисляет новый ключ: Capitalize<'name'> — это 'Name', поэтому шаблон даёт 'getName'
- 4 Выдаёт свойство под 'getName' со значением () => T['name'], то есть () => string
- 01Что делает оговорка `as` в отображённом типе и как построить Getters<T>, переименовывающий ключи?
- 02Как фильтровать ключи объекта по типу значения и почему ремаппинг ключа в never удаляет его?
- 03Какие два ограничения управляют ремаппингом ключей и чего стоит добавление `as`?
Оговорка as превратила сторону ключей отображённого типа в вычисление: переименование через шаблонные литералы или удаление через never для фильтрации по типу значения — и вы увидели, что эта мощь стоит вам гомоморфности. Здесь мы опирались на Capitalize и `get${...}` без полного объяснения. Дальше — шаблонные литеральные типы делают это главным событием: построение строковых типов интерполяцией, наблюдение, как объединения взрываются в декартово произведение, и использование infer внутри шаблонного образца для разбора строк — извлечение :id из /users/:id и разбиение по разделителям. Теперь, когда встречаете Getters<T> или DataOnly<T> в библиотечном коде, вы сразу видите оговорку as, прослеживаете правило переименования и знаете, какие модификаторы молча потерялись.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.