Що таке маповані типи в TypeScript
Mapped types (маповані типи) - TypeScript-конструкція, яка створює новий тип, перебираючи кожен ключ існуючого типу і застосовуючи трансформацію до типу його значення.
Теорія
TL;DR
- Аналог
Array.prototype.map()для типів об'єктів: вхід{name: string, age: number}, вихід{name: boolean, age: boolean}після зміни типу кожного значення - Базовий синтаксис:
{ [K in keyof T]: NewValueType }перебирає всі ключіT ?робить поля optional,readonlyзаморожує їх,-readonlyі-?прибирають модифікатори- Key remapping через
as(TypeScript 4.1+): фільтрація або перейменування ключів через template literals - Використовуй коли вбудовані утиліти (
Partial<T>,Pick<T, K>) не покривають потрібну трансформацію
Швидкий приклад
type User = {
name: string;
age: number;
};
// Замапити кожне поле на boolean
type BooleanUser = {
[K in keyof User]: boolean;
};
// Результат: { name: boolean; age: boolean }
const perms: BooleanUser = { name: true, age: false }; // okСинтаксис [K in keyof User] перебирає union ключів типу User і застосовує boolean як тип значення для кожного ключа. TypeScript генерує нову форму об'єкта при перевірці типів. У рантаймі нічого не виконується.
Головна різниця
Mapped types трансформують кожне поле систематично. Простий type alias лише перейменовує структуру, не чіпаючи її. Перетин (intersection) додає нові поля зверху. Mapped types реагують на джерельний тип: додай поле до User і BooleanUser підхопить його автоматично. Саме ця реактивність робить їх корисними для generic-утиліт.
Синтаксис модифікаторів
Чотири патерни покривають більшість випадків:
[K in keyof T]?: T[K]робить всі поля optional, аналогPartial<T>readonly [K in keyof T]: T[K]робить всі поля readonly, аналогReadonly<T>[K in keyof T]-?: T[K]прибирає optional з усіх полів, аналогRequired<T>-readonly [K in keyof T]: T[K]прибирає readonly, корисний як хелперMutable<T>
Префікс - в TypeScript означає "прибрати модифікатор". Модифікатори можна комбінувати з трансформацією значень в одному маппінгу.
Key remapping через as (TypeScript 4.1+)
Key remapping дозволяє перейменовувати або фільтрувати ключі під час маппінгу:
type EventHandlers<T> = {
[K in keyof T as K extends `on${string}` ? K : never]: T[K];
};
interface ButtonProps {
onClick: () => void;
label: string;
disabled?: boolean;
}
type Handlers = EventHandlers<ButtonProps>;
// Результат: { onClick: () => void }Клауза as запускає conditional для кожного ключа. Повертай never щоб прибрати ключ з результату. Повертай template literal type щоб перейменувати. Цей патерн часто зустрічається в бібліотеках компонентів, де потрібно відокремити event callbacks від DOM-атрибутів.
Гомоморфний маппінг
Mapped type є гомоморфним, коли він маппиться безпосередньо по keyof T. У цьому випадку TypeScript зберігає оригінальні модифікатори readonly і ? зі вхідного типу, якщо ти явно не перевизначаєш їх.
type User = { readonly id: string; name?: string };
type Copy = { [K in keyof User]: User[K] };
// Результат: { readonly id: string; name?: string } - модифікатори збереглисьМаппінги по довільних union, наприклад [K in string], гомоморфними не є і модифікатори не зберігають. Це часте джерело плутанини при роботі з index signatures.
Як TypeScript обробляє mapped types
Компілятор розкриває mapped type під час перевірки типів, підставляючи кожен ключ з union в клаузу маппінгу. Для [K in keyof T] він проходить по union ключів T і генерує новий літерал об'єктного типу для кожної підстановки. Runtime-коду не генерується: маповані типи стираються при компіляції так само, як і всі інші анотації типів.
Типові помилки
Помилка 1: очікування, що index signature збережеться після маппінгу
type Obj = { [key: string]: number };
type Bad = { [K in keyof Obj]?: Obj[K] };
// keyof Obj резолвиться в string | number
// але index signature з результату зникаєРішення: перетни результат маппінгу з index signature вручну:
type Good = { [K in keyof Obj]?: Obj[K] } & { [key: string]: number | undefined };Помилка 2: випадкове прибирання readonly з вхідного типу
type RUser = { readonly id: string };
type Bad = { [K in keyof RUser]: string };
// id: string - readonly зник, бо явний тип значення перевизначив йогоЩоб навмисно прибрати readonly, використовуй -readonly. Щоб зберегти - використовуй RUser[K] як тип значення і покладайся на гомоморфне збереження.
Помилка 3: вкладені маппінги без рекурсії
// Не працює для вкладених об'єктів - T[K] не трансформується рекурсивно
type ShallowPartial<T> = { [K in keyof T]?: Partial<T[K]> };Рішення - рекурсивний conditional type:
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;Помилка 4: спроба перейменувати ключі без клаузи as
// Тут можна змінити лише тип значення, не ключ
type Bad<T> = { [K in keyof T]: string };
// Правильно: використовуй `as` з Capitalize або template literals
type Good<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: T[K] };Де зустрічається
- TypeScript stdlib:
Partial<T>,Required<T>,Readonly<T>,Pick<T, K>іRecord<K, V>- все маповані типи під капотом - React:
React.ComponentProps<typeof Button>використовує mapped types для витягування типів HTML-атрибутів з компонента - TanStack Query:
UseQueryOptionsмаппить ключі на optional через{ [K in keyof T]?: T[K] } - tRPC: маппить форми входу і виходу процедур з модифікаторами для кожного endpoint
- Zod:
.partial()на схемі використовує той самий патерн з модифікатором?всередині
Питання на співбесіді
Q: Яка різниця між mapped types і conditional types?
A: Mapped types ітеруються по ключах для побудови форми об'єкта. Conditional types (T extends U ? A : B) розгалужуються за відношеннями між типами. Вони добре комбінуються: { [K in keyof T]: T[K] extends string ? Uppercase<T[K]> : T[K] } використовує обидва механізми в одному виразі.
Q: Що таке гомоморфний mapped type?
A: Маппінг безпосередньо по keyof T. Він зберігає оригінальні модифікатори readonly і ? з типу T, якщо ти явно не перевизначаєш їх. Маппінги по довільних union, наприклад [K in string], гомоморфними не є.
Q: Як побудувати глибокий Partial?
A: Через рекурсивний conditional type: type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;. Conditional зупиняє рекурсію на примітивах.
Q: Чи можна перейменовувати ключі за допомогою template literal types?
A: Так, з TypeScript 4.1. Клауза as приймає будь-який тип-вираз, що резолвиться в string | number | symbol | never, включно з template literal types на кшталт `on${Capitalize<string & K>}`.
Q: Реалізуй Diff<T, U> що прибирає з T ключі, присутні в U, зберігаючи оригінальні модифікатори.
A: type Diff<T, U extends keyof any> = { [K in keyof T as K extends U ? never : K]: T[K] };. Key remapping з conditional маппить виключені ключі в never, і TypeScript прибирає їх з результату. Оскільки маппінг іде по keyof T безпосередньо, він залишається гомоморфним і зберігає модифікатори.
Приклади
Базовий: трансформація типів значень
type Status = {
isActive: boolean;
isAdmin: boolean;
isVerified: boolean;
};
// Замінити всі boolean на string-мітки
type StatusLabels = {
[K in keyof Status]: string;
};
// Результат: { isActive: string; isAdmin: string; isVerified: string }Форма StatusLabels повністю повторює Status, але тип кожного значення замінено. Додай нове поле до Status і StatusLabels підхопить його автоматично.
Проміжний: витягування обробників подій з React props
interface FormProps {
onSubmit: (e: Event) => void;
onChange: (value: string) => void;
placeholder: string;
disabled?: boolean;
}
// Залишити лише ключі, що починаються з "on"
type HandlerProps<T> = {
[K in keyof T as K extends `on${string}` ? K : never]: T[K];
};
type FormHandlers = HandlerProps<FormProps>;
// Результат: { onSubmit: (e: Event) => void; onChange: (value: string) => void }Клауза as K extends \on${string}` ? K : neverфільтрує набір ключів. Будь-який ключ, що не відповідає патерну, маппиться вneverі зникає з результату. Той самий механізм лежить в основіReact.ComponentProps`.
Senior: перейменування ключів з умовним виключенням
type User = {
name: string;
age: number;
readonly id: string;
};
// Перейменувати `age` на `viewAge`, прибрати `id` з результату
type SecureUser = {
readonly [K in keyof User as
K extends 'id' ? never :
K extends 'age' ? 'viewAge' : K
]: User[K];
};
// Результат: { readonly name: string; readonly viewAge: number }Модифікатор readonly на маппінгу застосовується до всіх вихідних ключів. Поле id зникає, бо його ключ маппиться в never в клаузі as. Бачив цей патерн в трансформерах API-відповідей, де серверні назви полів потрібно змінювати перед передачею в UI, і перевірка на рівні типів відловлює невідповідності ще на етапі компіляції.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.