Отображенные типы (Mapped Types) представляют собой декларативный механизм трансформации структур данных в системе типов TypeScript. В основе Mapped Types лежит операция итерирования по конечному дискретному множеству строковых и числовых литералов ключей (K in keyof T), генерирующая новый объектный контракт.
С точки зрения компилятора Mapped Types разделяются на два фундаментальных класса:
[K in keyof T]. Компилятор глубоко анализирует исходный тип T и автоматически наследует его внутренние метаданные: модификаторы неизменяемости (readonly) и опциональности (?).[K in UnionKeys] или стандартный Record<K, V>. В них связь с оригинальным объектом разрывается, и результирующие поля генерируются в дефолтном (мутабельном и обязательном) состоянии.Начиная с версии TypeScript 4.1, семантическая выразительность Mapped Types вышла на принципиально новый уровень благодаря предложению Key Remapping via as (Переназначение ключей через as).
Синтаксис:
type Mapped<T> = {
[K in keyof T as NewKeyType<K>]: TransformedValue<T[K]>;
};Ключевое правило Key Remapping гласит: если выражение NewKeyType<K> вычисляется в тип never, данное свойство полностью исключается из результирующего интерфейса. Это позволило реализовать чистую фильтрацию свойств объекта на уровне типов без промежуточных костылей.
Конвейер переназначения ключей выполняет раздельную трансформацию индекса (ключа) и значения свойства:
+-------------------------------------------------------------------------+
| МЕХАНИКА KEY REMAPPING ЧЕРЕЗ КЛАУЗУ "as" |
| [K in keyof T as FilterOrRename<K>]: T[K] |
+-------------------------------------------------------------------------+
| |
| Исходный объект T: { id: string; secretToken: string; count: number } |
| |
| Шаг 1: Итерация по keyof T ("id" | "secretToken" | "count") |
| |
| Шаг 2: Вычисление клаузы 'as': |
| - "id" as `get${Capitalize<"id">}` => "getId" |
| - "secretToken" as never (Удаление свойства!) => NEVER |
| - "count" as `get${Capitalize<"count">}` => "getCount" |
| |
| Шаг 3: Формирование результирующего интерфейса: |
| { |
| getId: () => string; |
| getCount: () => number; |
| } |
+-------------------------------------------------------------------------+Управление модификаторами осуществляется операторами явного добавления (+) и явного вычитания (-):
-readonly: удаляет иммутабельность, превращая поля в мутабельные.
-?: удаляет опциональность, делая все поля строго обязательными (эквивалент Required<T>).
Компилятор извлекает список ключей через keyof T, сохраняя соответствие индексов исходным полям.
Для каждого ключа K исполняется предикат фильтрации или трансформации строки.
Все ключи, вычисленные в never, атомарно элиминируются из списка генерации полей.
Удаляются (-?) или добавляются (+readonly) флаги доступа на оставшихся свойствах.
Формируется оптимизированный объектный тип, готовый для использования в IDE и проверках компилятора.
Чем гомоморфный Mapped Type ([K in keyof T]) принципиально отличается от негомоморфного Record<keyof T, V>?
Getters<State>.PickByValue<UserEntity, string | number>).snake_case DTO в системные camelCase структуры на этапе компиляции.readonly при сборке сущностей в паттерне Builder.Оператор keyof T генерирует Union строковых, числовых и символьных литералов исходного типа.
Для каждого ключа вычисляется выражение переназначения. Если результат never — ключ отбрасывается.
Свойства обновляются: удаление или добавление флагов readonly и questionToken (опциональность).
Для сохраненных ключей вычисляется итоговый тип значения и генерируется финальный анонимный тип.
PickByValue и OmitByValue#В версиях до TypeScript 4.1 для фильтрации полей приходилось использовать громоздкие связки Pick<T, { [K in keyof T]: ... }[keyof T]>. Благодаря Key Remapping фильтрация реализуется в одну декларативную строчку:
// Извлечение только тех ключей, значения которых соответствуют ValueType
export type PickByValue<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};
// Исключение ключей, значения которых соответствуют ValueType
export type OmitByValue<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? never : K]: T[K];
};
interface UserAccount {
id: string;
username: string;
age: number;
isActive: boolean;
loginCount: number;
permissions: readonly string[];
deleteAccount: () => Promise<void>;
}
// 1. Извлекаем только строковые свойства: { id: string; username: string }
export type StringProps = PickByValue<UserAccount, string>;
// 2. Извлекаем только числовые свойства: { age: number; loginCount: number }
export type NumericProps = PickByValue<UserAccount, number>;
// 3. Отсекаем методы, оставляя только данные (Serialised DTO):
export type DataOnly = OmitByValue<UserAccount, Function>;Клауза as идеально интегрируется со строковыми манипуляциями:
// Генерация геттеров: для свойства "name: string" генерируется "getName: () => string"
export type CreateGetters<T> = {
[K in keyof T as K extends string ? `get${Capitalize<K>}` : never]: () => T[K];
};
// Генерация сеттеров: для свойства "age: number" генерируется "setAge: (val: number) => void"
export type CreateSetters<T> = {
[K in keyof T as K extends string ? `set${Capitalize<K>}` : never]: (value: T[K]) => void;
};
interface AppConfig {
apiUrl: string;
port: number;
debugMode: boolean;
}
export type AppConfigAccessors = CreateGetters<AppConfig> & CreateSetters<AppConfig>;
/*
Результирующий интерфейс AppConfigAccessors содержит:
{
getApiUrl: () => string;
getPort: () => number;
getDebugMode: () => boolean;
setApiUrl: (value: string) => void;
setPort: (value: number) => void;
setDebugMode: (value: boolean) => void;
}
*/DeepMutable и DeepRequired#Стандартные утилиты Readonly<T> и Required<T> являются поверхностными (Shallow). Используя рекурсивные Mapped Types со снятием модификаторов -readonly и -?, строятся глубокие преобразователи:
// Рекурсивное снятие модификатора readonly со всех уровней вложенности
export type DeepMutable<T> = {
-readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: DeepMutable<T[K]>
: T[K];
};
// Рекурсивное снятие модификатора опциональности (?)
export type DeepRequired<T> = {
[K in keyof T]-?: T[K] extends object
? T[K] extends Function
? T[K]
: DeepRequired<T[K]>
: T[K];
};
interface ImmutableState {
readonly user: {
readonly profile: {
readonly bio?: string;
readonly socialLinks?: readonly string[];
};
};
}
// Превращаем структуру в полностью мутабельную:
export type MutableDraft = DeepMutable<ImmutableState>;
// MutableDraft.user.profile.bio теперь доступен для записи
// Превращаем структуру в строго обязательную (без undefined):
export type StrictState = DeepRequired<ImmutableState>;
// StrictState.user.profile.bio теперь строго 'string', а не 'string | undefined'Разработаем типобезопасный адаптер сущностей, конвертирующий доменные модели в публичные DTO с автоматическим удалением приватных полей и методов:
// Исключаем приватные поля (начинающиеся с подчёркивания) и пароли
type IsExposedKey<K> = K extends `_${string}`
? false
: K extends `${string}Password${string}`
? false
: K extends `${string}Secret${string}`
? false
: true;
// Готовый трансформатор публичного контракта
export type PublicDTO<TEntity> = {
-readonly [K in keyof TEntity as IsExposedKey<K> extends true
? TEntity[K] extends Function
? never
: K
: never]: TEntity[K] extends object
? TEntity[K] extends readonly unknown[]
? TEntity[K]
: PublicDTO<TEntity[K]>
: TEntity[K];
};
// Тестовая доменная модель
interface UserDomainModel {
id: string;
email: string;
_internalDatabaseClusterId: number;
hashedPasswordSalt: string;
billingSecretToken: string;
profile: {
displayName: string;
_cacheVersion: number;
};
saveToDatabase: () => Promise<void>;
validatePassword: (pwd: string) => boolean;
}
export type SafeUserDTO = PublicDTO<UserDomainModel>;
/*
Компилятор чисто формирует безопасный контракт SafeUserDTO:
{
id: string;
email: string;
profile: {
displayName: string;
};
}
Все методы, приватные поля (_) и секреты удалены автоматически!
*/Почему попытка отфильтровать свойства установкой значения в never ({ [K in keyof T]: T[K] extends V ? T[K] : never }) считается антипаттерном?
Частая ошибка начинающих разработчиков: попытка удалить поле через T[K] extends V ? T[K] : never без клаузы as. Это не удаляет свойство из интерфейса, а создает ключ со значением типа never. Значение never невозможно присвоить, однако сам ключ сохраняется в keyof, ломая проверки Object.keys() и валидаторы.
// ❌ СЛОМАНО: оставляет ключ со значением never ({ name: string, age: never })
type BadFilter<T> = {
[K in keyof T]: T[K] extends string ? T[K] : never;
};
// ✅ ПРАВИЛЬНО: удаление ключа через клаузу 'as never' ({ name: string })
type GoodFilter<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};Тип keyof T объединяет не только string, но также number и symbol. Передача абстрактного K в Capitalize<K> вызывает ошибку компиляции TS2344, так как Capitalize требует string. Необходимо сужать ключ через K extends string или string & K.
// ❌ ОШИБКА TS2344: Type 'K' does not satisfy the constraint 'string'
type BrokenGetters<T> = {
[K in keyof T as `get${Capitalize<K>}`]: () => T[K];
};
// ✅ ПРАВИЛЬНО: безопасное сужение ключа
type SafeGetters<T> = {
[K in keyof T as K extends string ? `get${Capitalize<K>}` : never]: () => T[K];
};Использование Record<keyof T, Value> вместо гомоморфного [K in keyof T] приводит к полной потере метаинформации об опциональных полях исходного типа, превращая все опциональные поля в обязательные.
Рекурсивные Mapped Types не должны спускаться в системные типы или циклические графы (HTMLElement, Function, Promise). Всегда ставьте терминальные условия отсечения сложных структур данных.