Mapped types: типы из типов
Представьте, что вам дали интервью и просят: «Напишите свой аналог встроенного типа Partial<T>, который делает все поля объекта необязательными». Многие теряются — кажется, что это какая-то магия компилятора. На самом деле это обычный синтаксис, который называется mapped type («отображаемый тип»), и его вполне реально написать за две минуты, если понимать один приём.
Идея простая: у вас есть тип с ключами (например, интерфейс), и вы хотите пройтись по каждому его ключу и построить на основе него новый тип. Звучит как цикл for, только не по значениям массива, а по ключам типа во время компиляции.
Как выглядит объект, который мы будем переделывать
Возьмём простой тип пользователя:
interface User {
id: number;
name: string;
email: string;
}Допустим, у нас есть функция обновления пользователя — updateUser. Обновлять хочется не все поля сразу, а только некоторые. Значит, нужен тип, где id, name и email — необязательные. Переписывать интерфейс руками с тремя знаками вопроса — не вариант, если полей будет 20, а изменения в User должны сами долетать до этого «черновика».
Синтаксис mapped type
Вот как выглядит конструкция, которая решает эту задачу:
type MyPartial<T> = {
[K in keyof T]?: T[K];
};Разберём это по кусочкам, потому что тут сразу три новых для новичка вещи:
keyof T— берёт тип объектаTи превращает его в объединение (union) его ключей. ДляUserэто будет"id" | "name" | "email"— то есть тип, который может быть строкой"id", либо"name", либо"email".[K in ...]— это и есть сам «цикл». Он берёт объединение ключей и проходится по каждому из них, называя текущий ключK. Работает похоже наfor...inв обычном JavaScript, только выполняется во время компиляции, а не во время исполнения программы.T[K]— это способ достать тип значения по ключуKу типаT. ЕслиKсейчас равен"name", тоT[K]— этоstring, потому что именно такой тип у поляname.
Знак ? после [K in keyof T] делает каждое сгенерированное поле необязательным — ровно то, что делает встроенный Partial<T>.
Что выведет компилятор
Проверим на практике, применив наш тип:
type DraftUser = MyPartial<User>;
const draft: DraftUser = { name: "Аня" };
console.log(draft);Вывод:
{ name: 'Аня' }Но самое интересное происходит не в консоли, а в редакторе кода: если навести курсор на DraftUser, TypeScript покажет тип, эквивалентный { id?: number; name?: string; email?: string } — компилятор честно развернул его для нас. И если попробовать написать const draft: DraftUser = { age: 20 }, где поля age вообще нет в User, компилятор сразу выдаст ошибку — необязательность полей не значит вседозволенность.
Как это работает под капотом
Когда компилятор встречает MyPartial<User>, он делает по сути следующее: сначала вычисляет keyof User, получает union "id" | "name" | "email". Дальше для каждого члена этого union он генерирует одно свойство результирующего объекта, беря его тип через индексный доступ T[K]. В итоге получается плоский объектный тип — как будто вы написали его руками, но фактически это сделал компилятор по шаблону.
Модификаторы можно не только добавлять, но и убирать. Например, есть встроенный Required<T>, который делает поля обязательными — снимает ?, даже если он был у исходного типа:
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};Минус перед ? означает «убрать этот модификатор», а не «поставить минус». Так же работает -readonly — убирает пометку «только для чтения» у полей.
Ещё один приём продвинутого уровня — переименование ключей прямо во время маппинга через ключевое слово as. Например, можно превратить каждое поле name в геттер getName:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string; getEmail: () => string }Здесь Capitalize — встроенный служебный тип, который делает первую букву строки заглавной уже на уровне типов, а конструкция с обратными кавычками — это шаблонный литеральный тип, буквально «шаблонная строка», только для типов, а не для значений.
Частые ошибки на собеседовании
Первая ошибка — путать keyof T с самим T. Кандидаты часто пишут [K in T] вместо [K in keyof T] и удивляются, почему компилятор ругается: T — это тип объекта целиком, а не список его ключей, по объекту напрямую пройтись нельзя.
Вторая — забыть, что T[K] работает только тогда, когда K действительно является ключом T. Если написать индексный доступ с произвольной строкой, которой нет среди ключей, компилятор укажет на ошибку — и это признак того, что вы понимаете, откуда вообще берётся такая защита.
Третья, чуть более тонкая — не путать модификатор ? у mapped type с обычным опциональным полем в интерфейсе. Смысл одинаковый (поле может отсутствовать), но конструкция мэппинга умеет применять этот модификатор сразу ко всем полям одним махом, а не переписывать их вручную одно за другим.
Итоги-шпаргалка
keyof Tпревращает объектный тип в union его ключей — строк-литералов.[K in keyof T]: ...— это цикл по ключам на уровне типов, аналогfor...in, только во время компиляции.T[K]достаёт тип значения по ключу — работает как индекс в обычном объекте, только для типов.?и-?,readonlyи-readonly— модификаторы, которые можно добавлять и снимать при маппинге.asвнутри[K in keyof T as ...]позволяет переименовывать ключи на лету — так устроены продвинутые утилиты вроде генераторов геттеров.