Skip to main content

Що таке маповані типи в 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>) не покривають потрібну трансформацію

Швидкий приклад

typescript
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 дозволяє перейменовувати або фільтрувати ключі під час маппінгу:

typescript
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 і ? зі вхідного типу, якщо ти явно не перевизначаєш їх.

typescript
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 збережеться після маппінгу

typescript
type Obj = { [key: string]: number }; type Bad = { [K in keyof Obj]?: Obj[K] }; // keyof Obj резолвиться в string | number // але index signature з результату зникає

Рішення: перетни результат маппінгу з index signature вручну:

typescript
type Good = { [K in keyof Obj]?: Obj[K] } & { [key: string]: number | undefined };

Помилка 2: випадкове прибирання readonly з вхідного типу

typescript
type RUser = { readonly id: string }; type Bad = { [K in keyof RUser]: string }; // id: string - readonly зник, бо явний тип значення перевизначив його

Щоб навмисно прибрати readonly, використовуй -readonly. Щоб зберегти - використовуй RUser[K] як тип значення і покладайся на гомоморфне збереження.

Помилка 3: вкладені маппінги без рекурсії

typescript
// Не працює для вкладених об'єктів - T[K] не трансформується рекурсивно type ShallowPartial<T> = { [K in keyof T]?: Partial<T[K]> };

Рішення - рекурсивний conditional type:

typescript
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;

Помилка 4: спроба перейменувати ключі без клаузи as

typescript
// Тут можна змінити лише тип значення, не ключ 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 безпосередньо, він залишається гомоморфним і зберігає модифікатори.

Приклади

Базовий: трансформація типів значень

typescript
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

typescript
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: перейменування ключів з умовним виключенням

typescript
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, і перевірка на рівні типів відловлює невідповідності ще на етапі компіляції.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?