TypeScriptMiddleCoding

Что такое mapped types и как создать пользовательский mapped type?

Mapped type создаёт новый тип итерацией по ключам другого: { [K in keyof T]: ... }. Поддерживает модификаторы readonly/?, их удаление через минус, и переименование ключей через as с шаблонными литералами.

Что такое mapped types

Mapped type — это способ создать новый тип, итерируясь по ключам другого типа и трансформируя их. Синтаксис: { [K in SomeKeys]: TransformedType }. Это механизм, лежащий в основе большинства встроенных утилитарных типов TypeScript.

Базовый синтаксис

type Flags<T> = {
  [K in keyof T]: boolean;
};

interface User {
  id: number;
  name: string;
  email: string;
}

type UserFlags = Flags<User>;
// { id: boolean; name: boolean; email: boolean }

Модификаторы: readonly и ?

Знаки + и - добавляют или убирают модификаторы у каждого ключа:

// Добавить readonly (+ по умолчанию)
type Immutable<T> = {
  readonly [K in keyof T]: T[K];
};

// Убрать readonly
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

// Убрать опциональность (аналог Required)
type Concrete<T> = {
  [K in keyof T]-?: T[K];
};

Переименование ключей через as (remapping)

С TypeScript 4.1 можно переименовывать ключи с помощью as:

// Добавить префикс get к каждому ключу
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string; getEmail: () => string }

// Отфильтровать ключи: remapping в never удаляет ключ
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type StringUserFields = OnlyStrings<User>;
// { name: string; email: string }

Пользовательский mapped type: DeepReadonly

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

interface Config {
  server: {
    host: string;
    port: number;
  };
  debug: boolean;
}

type ImmutableConfig = DeepReadonly<Config>;
// config.server.host — только для чтения на любой глубине

Mapped type по произвольному union

В качестве источника ключей можно использовать любой string/number literal union, не только keyof T:

type HttpMethods = "GET" | "POST" | "PUT" | "DELETE";

type MethodHandlers = {
  [M in HttpMethods]: (url: string, body?: unknown) => Promise<Response>;
};

const client: MethodHandlers = {
  GET:    (url) => fetch(url),
  POST:   (url, body) => fetch(url, { method: "POST", body: JSON.stringify(body) }),
  PUT:    (url, body) => fetch(url, { method: "PUT",  body: JSON.stringify(body) }),
  DELETE: (url) => fetch(url, { method: "DELETE" }),
};

Встроенные утилиты, реализованные через mapped types

  • Partial<T> — все поля опциональны
  • Required<T> — все поля обязательны
  • Readonly<T> — все поля только для чтения
  • Record<K, V> — объект с ключами K и значениями V
  • Pick<T, K> — подмножество полей
  • Omit<T, K> — исключение полей

Подводные камни

  • Remapping ключа в never удаляет его из типа — полезно, но легко допустить ошибку в условии и потерять нужные поля.
  • Mapped types работают поверхностно по умолчанию: вложенные объекты не трансформируются автоматически, нужна явная рекурсия.
  • Рекурсивные mapped types могут вызвать ошибку «Type alias circularly references itself» — обходите через условный тип или interface.
  • При использовании Capitalize в as-remapping ключ должен быть string & K, а не просто K, иначе компилятор жалуется на символьные ключи.
  • Record<string, V> и mapped type по string — разные вещи по поведению с index signature; не всегда взаимозаменяемы.
  • Модификатор -? (убрать опциональность) не делает тип non-nullable — поля могут по-прежнему быть undefined, если это часть их типа.
  • Большие mapped types с ремаппингом замедляют автодополнение; разбивайте на промежуточные алиасы.

Common mistakes

  • Смешивать «mapped types» с похожим механизмом без критерия выбора.
  • Игнорировать риск: неверно оценить границы применения темы «mapped types» и получить хрупкое решение.
  • Показывать только синтаксис и не объяснять поведение в runtime или сборке.

What the interviewer is testing

  • Объясняет преобразование свойств типа по ключам.
  • Показывает на примере, как работает: mapped type проходит по keyof исходного типа и создает новую объектную форму, при необходимости меняя optional, readonly или имена ключей.
  • Называет production-нюанс и граничный случай для темы «mapped types».

Sources

Related topics