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 ...] позволяет переименовывать ключи на лету — так устроены продвинутые утилиты вроде генераторов геттеров.
Проверьте себя
1. Что делает конструкция [K in keyof T] в mapped type?
AПроверяет во время выполнения, есть ли ключ K в объекте T
BПроходится по каждому ключу типа T на этапе компиляции и позволяет построить на его основе новое свойство
CКопирует значения полей объекта T в новый объект в рантайме
DУдаляет из типа T все ключи, кроме K
2. Как в mapped type снять модификатор readonly у всех полей, даже если он был в исходном типе?
A[K in keyof T]: readonly T[K]
B[K in keyof T]!: T[K]
C[K in keyof T]-readonly: T[K]
D-readonly [K in keyof T]: T[K]