Відображені типи (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>).
Getters<State>.PickByValue<UserEntity, string | number>).snake_case DTO на внутрішні camelCase структури на етапі компіляції.readonly при збиранні сутностей у патерні Builder.Оператор keyof T генерує Union рядкових, числових та символьних літералів вихідного типу.
Для кожного ключа обчислюється вираз перепризначення. Якщо результат never — ключ відкидається.
Властивості оновлюються: видалення або додавання прапорів readonly та questionToken (опціональність).
Для збережених ключів обчислюється підсумковий тип значення та генерується фінальний анонімний тип.
PickByValue та OmitByValue#У версіях до TypeScript 4.1 для фільтрації полів доводилося використовувати громіздкі конструкції. Завдяки 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. Відсікаємо методи, залишаючи тільки дані (Серіалізований 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;
};
}
Усі методи, приватні поля (_) та секрети видалені автоматично!
*/Часта помилка розробників: спроба видалити поле через 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). Завжди ставте термінальні умови відсікання складних структур даних.
Компілятор вилучає список ключів через keyof T, зберігаючи відповідність індексів вихідним полям.
Для кожного ключа K виконується предикат фільтрації або трансформації рядка.
Усі ключі, обчислені в never, атомарно елімінуються зі списку генерації полів.
Видаляються (-?) або додаються (+readonly) прапори доступу на залишених властивостях.
Формується оптимізований об'єктний тип, готовий для використання в IDE та перевірках компілятора.