$ sudo teach IT
МОДУЛЬ 7 · УРОК 6

Mapped Types и Indexed Access Types

Отображённые типы и типы с индексным доступом — мощные инструменты для трансформации и навигации по типам в TypeScript

⏱ ~30 мин 📈 Средний уровень 📝 8 заданий

Введение

Представьте, что у вас есть шаблон — например, список покупок. Вы можете взять этот шаблон и автоматически создать его копию, изменив формат каждой записи: вместо списка товаров сделать список цен, вместо цен — список количеств, а вместо количеств — список статусов «куплено/не куплено». При этом сама структура (названия товаров) остаётся прежней.

Именно так работают mapped types (отображённые типы) в TypeScript. Они берут существующий тип, перебирают все его свойства и создают новый тип, трансформируя каждое свойство по определённому правилу. Это как «машинка», которая принимает на входе тип и выдаёт модифицированную версию.

А Indexed Access Types (типы с индексным доступом) — это способ «достучаться» до конкретного типа внутри сложной структуры. Если mapped types — это машина для трансформации всего объекта, то indexed access types — это пинцет, который вытаскивает конкретный элемент из типа.

В этом уроке мы подробно разберём оба инструмента: сначала изучим mapped types — их синтаксис, модификаторы, встроенные типы. Затем поговорим об indexed access types. И, наконец, увидим, как их комбинировать для решения реальных задач.

1. Что такое mapped types — синтаксис { [K in keyof T]: ... }

Mapped type — это тип, который «отображает» (map) все свойства одного типа на свойства другого типа. Звучит абстрактно, поэтому давайте начнём с простого примера.

Предположим, у вас есть тип пользователя:

type User = {
  name: string;
  age: number;
  email: string;
};

Теперь вы хотите создать новый тип, где каждое свойство User станет опциональным (то есть необязательным). Без mapped types вам пришлось бы вручную перечислить каждое свойство:

type PartialUser = {
  name?: string;
  age?: number;
  email?: string;
};

Это работает, но если у вас 50 свойств — это катастрофа. Каждое изменение в исходном типе требует ручного обновления. Mapped types решают эту проблему автоматически.

Синтаксис mapped type выглядит так:

type MappedType = {
  [K in keyof T]: NewValueType;
};

Разберём каждый кусочек:

  • keyof T — получает все ключи типа T как union-тип строковых литералов. Для User это будет 'name' | 'age' | 'email'.
  • K — это переменная, которая на каждой итерации принимает одно значение из этого union. Сначала K = 'name', потом K = 'age', потом K = 'email'.
  • [K in keyof T] — цикл по всем ключам. Как for (let K of ['name', 'age', 'email']), но на уровне типов.
  • NewValueType — тип, на который трансформируется каждое свойство. Может зависеть от K.

Применим это к нашему примеру:

type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

type PartialUser = MyPartial<User>;
// Результат:
// {
//   name?: string;
//   age?: number;
//   email?: string;
// }

Обратите внимание: T[K] — это indexed access type, который мы изучим позже. Пока просто знайте, что он «достаёт» тип значения для ключа K из типа T.

Аналогия: Представьте тип как паспорт с полями: имя, возраст, адрес. Mapped type — это автомат, который берёт ваш паспорт и создаёт новую анкету, копируя все поля, но меняя их правила заполнения (например, делая все поля необязательными).

Важно: Mapped types всегда создают новый тип. Они не изменяют исходный тип — TypeScript работает с иммутабельными типами. Вы создаёте производный тип на основе базового.

2. keyof — получение ключей типа

Прежде чем углубляться в mapped types, давайте разберём keyof — оператор, который лежит в основе отображённых типов.

keyof T возвращает union-тип всех публичных ключей типа T:

type User = {
  name: string;
  age: number;
  email: string;
};

type UserKeys = keyof User;
// Результат: 'name' | 'age' | 'email'

Это.union строковых литералов. Теперь вы можете использовать этот union как тип параметра функции:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user: User = { name: "Анна", age: 25, email: "anna@example.com" };

const name = getProperty(user, "name"); // string
const age = getProperty(user, "age");   // number
// getProperty(user, "phone"); // Ошибка! "phone" не ключ User

Здесь K extends keyof T означает: «K должен быть одним из ключей T». Это гарантирует, что вы не обратитесь к несуществующему ключу.

Аналогия: keyof — это как меню ресторана. Оно перечисляет все доступные блюда (ключи). Вы не можете заказать то, чего нет в меню — TypeScript не позволит.

keyof работает и с индексными сигнатурами:

type StringMap = {
  [key: string]: number;
};

type StringMapKeys = keyof StringMap;
// Результат: string

Когда тип имеет индексную сигнатуру, keyof возвращает тип ключа (в данном случае string), а не конкретные литералы.

keyof также работает с типами классов:

class Person {
  name: string = "";
  age: number = 0;
}

type PersonKeys = keyof Person;
// Результат: 'name' | 'age'

3. Базовые mapped types — трансформация всех свойств

Теперь, когда мы знаем keyof, давайте создадим более сложные mapped types. Главная сила отображённых типов — в ability менять тип каждого свойства.

Допустим, мы хотим создать тип, где все свойства строки становятся типом string[] (массивом строк):

type StringArrays<T> = {
  [K in keyof T]: string[];
};

type UserLists = StringArrays<User>;
// Результат:
// {
//   name: string[];
//   age: string[];
//   email: string[];
// }

Или мы можем сделать все свойства readonly:

type Immutable<T> = {
  readonly [K in keyof T]: T[K];
};

type FrozenUser = Immutable<User>;
// Результат:
// {
//   readonly name: string;
//   readonly age: number;
//   readonly email: string;
// }

Обратите внимание: T[K] — это indexed access type. Он «смотрит» на тип значения для ключа K в типе T. Если K = 'name', то T[K] = string. Если K = 'age', то T[K] = number.

Мы можем комбинировать несколько трансформаций:

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

type RelaxedUser = ReadonlyOptional<User>;
// Результат:
// {
//   readonly name?: string;
//   readonly age?: number;
//   readonly email?: string;
// }

Порядок имеет значение! Сначала указываются модификаторы (readonly, ?), затем ключ, затем тип значения.

Аналогия: Mapped type — это как рецепт выпечки. У вас есть набор ингредиентов (ключи исходного типа), и вы решаете, что с каждым делать: нарезать кубиками, растереть в порошок, оставить как есть. Результат — новый набор, но с теми же ингредиентами.

Совет: Не путайте порядок модификаторов. readonly [K in keyof T] — правильно. [K in keyof T] readonly — ошибка синтаксиса.

Давайте создадим несколько полезных базовых mapped types:

type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

type NullableUser = Nullable<User>;
// Результат:
// {
//   name: string | null;
//   age: number | null;
//   email: string | null;
// }
type WithDefault<T, D> = {
  [K in keyof T]: T[K] extends undefined ? D : T[K];
};

type UserWithDefaults = WithDefault<User, string>;
// Результат:
// {
//   name: string;    // уже string, остался
//   age: number;     // не extends undefined, остался
//   email: string;   // уже string, остался
// }

Здесь мы использовали conditional type (extends), но об этом подробнее позже, когда дойдём до комбинирования mapped и conditional types.

4. Модификаторы маппинга — readonly и optional (?)

В mapped types можно использовать два модификатора: readonly и ? (optional). Они работают так же, как и в обычных типах, но применяются ко всем свойствам одновременно.

Добавление readonly:

type AllReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

type Config = {
  apiUrl: string;
  timeout: number;
  retries: number;
};

type ImmutableConfig = AllReadonly<Config>;
// Все свойства теперь readonly
// immutableConfig.apiUrl = "new"; // Ошибка!

Добавление optional:

type AllOptional<T> = {
  [K in keyof T]?: T[K];
};

type UserUpdate = AllOptional<User>;
// Можно передавать частичные объекты
const update: UserUpdate = { name: "Новое имя" }; // OK!

Комбинация обеих:

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

Аналогия: Добавить readonly — это как запечатать все файлы в папке в конверты. Можно посмотреть, но нельзя изменить. Добавить optional — это как сделать все поля анкеты необязательными: заполняйте только те, которые хотите.

Важный нюанс: модификаторы в mapped types добавляются к существующим. Если исходное свойство уже readonly, добавление readonly в mapped type ничего не меняет. Но если исходное свойство не readonly, оно становится readonly.

type Mixed = {
  readonly a: string;
  b: number;
};

type AllReadonlyMixed<T> = {
  readonly [K in keyof T]: T[K];
};

type Result = AllReadonlyMixed<Mixed>;
// a: readonly (уже был)
// b: readonly (добавлен)

5. Удаление модификаторов — -readonly и -?

TypeScript позволяет не только добавлять, но и удалять модификаторы с помощью префиксов -. Это мощная возможность.

Удаление optional:

type RequiredAll<T> = {
  [K in keyof T]-?: T[K];
};

type PartialUser = {
  name?: string;
  age?: number;
  email?: string;
};

type FullUser = RequiredAll<PartialUser>;
// Результат:
// {
//   name: string;    // ? удалено
//   age: number;     // ? удалено
//   email: string;   // ? удалено
// }

Префикс - перед ? означает: «удалить модификатор optional». Без этого префикса мы бы просто повторили модификатор (что бесполезно).

Удаление readonly:

type MutableAll<T> = {
  -readonly [K in keyof T]: T[K];
};

type FrozenUser = {
  readonly name: string;
  readonly age: number;
};

type MutableUser = MutableAll<FrozenUser>;
// Результат:
// {
//   name: string;    // readonly удалено
//   age: number;     // readonly удалено
// }

Аналогия: Префикс - — это как «разблокировка». Если readonly — это запечатанный конверт, то -readonly — это вскрытие конверта. Если optional — это поле, которое можно пропустить, то -? — это требование заполнить все поля.

Типичный сценарий: у вас есть тип с опциональными свойствами (для API-ответа), и вы хотите получить тип, где все свойства обязательны (для сохранения в базу данных):

type ApiResponse = {
  id?: number;
  name?: string;
  email?: string;
};

type DatabaseRecord = Required<ApiResponse>;
// {
//   id: number;      // обязательное
//   name: string;    // обязательное
//   email: string;   // обязательное
// }

Это одна из самых частых practical применений mapped types с удалением модификаторов.

Факт: Встроенные типы Required<T> и Readonly<T> используют именно -? и readonly соответственно. TypeScript ничего не «знает» о них магически — они реализованы через mapped types.

6. Встроенные mapped types: Partial, Required, Readonly, Pick, Record

TypeScript поставляется с набором встроенных mapped types. Теперь вы понимаете, как они работают под капотом.

Partial<T> — делает все свойства опциональными:

type Partial<T> = {
  [K in keyof T]?: T[K];
};

// Использование:
function updateUser(user: User, updates: Partial<User>) {
  return { ...user, ...updates };
}

updateUser({ name: "Анна", age: 25, email: "a@b.com" }, { age: 26 });
// OK — обновляем только age

Required<T> — делает все свойства обязательными:

type Required<T> = {
  [K in keyof T]-?: T[K];
};

type UserForm = Partial<User>; // все опционально
type ValidUser = Required<UserForm>; // все обязательно

Readonly<T> — делает все свойства readonly:

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

const config: Readonly<Config> = { apiUrl: "...", timeout: 5000, retries: 3 };
// config.apiUrl = "..."; // Ошибка!

Pick<T, K> — выбирает только указанные ключи:

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type UserBasic = Pick<User, 'name' | 'email'>;
// Результат:
// {
//   name: string;
//   email: string;
// }

Обратите внимание: P in K — здесь K не keyof T, а конкретный union-тип, переданный вторым аргументом. Pick «фильтрует» свойства.

Record<K, T> — создаёт объект с ключами K и значениями типа T:

type Record<K extends keyof any, T> = {
  [P in K]: T;
};

type UserRoles = Record<'admin' | 'user' | 'guest', boolean>;
// Результат:
// {
//   admin: boolean;
//   user: boolean;
//   guest: boolean;
// }

const roles: UserRoles = {
  admin: true,
  user: false,
  guest: false,
};

Record особенно полезен для создания «словарей» — объектов, где ключи известны, а значения одного типа.

Аналогия: Partial — это анкета, где можно пропускать вопросы. Required — это анкета, где все вопросы обязательны. Readonly — это заполненная анкета, которую нельзя править. Pick — это вырванная страница из анкеты. Record — это чистый бланк с заранее напечатанными вопросами.

Совет: Record<K, V> с пустым union ключей (Record<never, any>) создаёт пустой тип. Это иногда используется для «очистки» типа.

7. Indexed Access Types — синтаксис T[K]

Indexed Access Types (типы с индексным доступом) позволяют получить тип конкретного свойства из типа объекта. Синтаксис: T[K].

Это похоже на обращение к свойству объекта во время выполнения (obj[key]), но работает на уровне типов:

type User = {
  name: string;
  age: number;
  email: string;
};

type UserName = User['name'];      // string
type UserAge = User['age'];        // number
type UserEmail = User['email'];    // string

Вы можете передать любой ключ, который существует в типе. Если ключа нет — TypeScript выдаст ошибку:

type UserPhone = User['phone']; // Ошибка: ключ 'phone' не существует в типе 'User'

Получение типа для нескольких ключей: Если передать union ключей, вы получите union типов значений:

type UserNameOrAge = User['name' | 'age']; // string | number
type UserNameOrAgeOrEmail = User[keyof User]; // string | number | string
// Упрощается до: string | number

Аналогия: Indexed access type — это как запрос в базе данных: SELECT name FROM users. Вы указываете таблицу (тип) и столбец (ключ), и получаете тип данных этого столбца.

Это работает и с вложенными типами:

type Company = {
  name: string;
  address: {
    city: string;
    street: string;
    zip: string;
  };
};

type CompanyCity = Company['address']['city']; // string
type CompanyAddress = Company['address']; // { city: string; street: string; zip: string; }

Вы можете «спускаться» по цепочке, как при обращению к вложенным свойствам объекта.

Indexed access types также работают с массивами и кортежами:

type Numbers = number[];
type FirstNumber = Numbers[0]; // number

type Tuple = [string, number, boolean];
type First = Tuple[0];   // string
type Second = Tuple[1];  // number
type Third = Tuple[2];   // boolean

8. Indexed access types с union-типами

Когда вы используете indexed access type с union ключей, TypeScript возвращает union значений. Это открывает интересные возможности.

type User = {
  name: string;
  age: number;
  email: string;
  isActive: boolean;
};

type UserStringProps = User['name' | 'email']; // string | string → string
type UserPrimitiveProps = User['name' | 'age' | 'email']; // string | number | string → string | number

TypeScript автоматически упрощает union-типы. Если несколько ключей имеют один и тот же тип, он не дублирует его.

Получение всех типов значений:

type UserValues = User[keyof User]; // string | number | boolean

Это полезно, когда вы хотите тип, который «допускает» любое значение из объекта.

Фильтрация ключей: С помощью indexed access types можно реализовать фильтрацию ключей по типу значений:

type StringKeys<T> = {
  [K in keyof T]: T[K] extends string ? K : never;
}[keyof T];

type UserStringKeys = StringKeys<User>; // 'name' | 'email'

Разберём этот пример:

  1. [K in keyof T]: T[K] extends string ? K : never — для каждого ключа: если значение — строка, вернуть ключ, иначе never.
  2. [keyof T] — indexed access, который собирает все результаты в union.
  3. never исчезает из union автоматически (это свойство never).

Результат: только ключи, чьи значения — строки.

Аналогия: Это как сортировка. Берём все столбцы таблицы, смотрим на тип данных каждого, и оставляем только те, где тип — строка. Как фильтр в Excel по типу ячейки.

Совет: Паттерн { [K in keyof T]: Condition ? K : never }[keyof T] — это классический способ фильтрации ключей типа. Запомните его — он пригодится часто.

9. Комбинирование mapped types с conditional types

Conditional types (условные типы) позволяют выбирать тип в зависимости от условия. Когда их комбинируют с mapped types, получается очень мощный инструментарий.

Синтаксис conditional type: T extends U ? X : Y — если T наследуется от U, результат X, иначе Y.

Пример: mapped type, который оборачивает все функции в тип Promise:

type AsyncMethods<T> = {
  [K in keyof T]: T[K] extends (...args: any[]) => any
    ? (...args: Parameters<T[K]>) => Promise<ReturnType<T[K]>>
    : T[K];
};

type ApiClient = {
  getUser: (id: number) => User;
  getPosts: () => Post[];
  baseUrl: string;
};

type AsyncApiClient = AsyncMethods<ApiClient>;
// Результат:
// {
//   getUser: (id: number) => Promise<User>;
//   getPosts: () => Promise<Post[]>;
//   baseUrl: string; // не функция — осталась как есть
// }

Здесь:

  • T[K] extends (...args: any[]) => any — условие: является ли значение функцией?
  • Если да — оборачиваем в Promise.
  • Если нет — оставляем как есть.

Ещё пример: тип, который делает все свойства-массивы readony:

type ReadonlyArrays<T> = {
  [K in keyof T]: T[K] extends readonly any[]
    ? Readonly<T[K]>
    : T[K];
};

Аналогия: Это как сортировщик на конвейере. Каждый элемент (свойство) проходит проверку: «Ты яблоко?» Если да — в корзину яблок, если нет — в корзину груш. Mapped type — это конвейер, conditional type — это сортировщик.

Давайте создадим более сложный пример: тип, который выбирает только опциональные свойства:

type OptionalProps<T> = {
  [K in keyof T]-?: undefined extends T[K] ? K : never;
}[keyof T];

type PartialUser = {
  name: string;
  age?: number;
  email?: string;
  phone?: string;
};

type UserOptionalKeys = OptionalProps<PartialUser>; // 'age' | 'email' | 'phone'

Разберём:

  1. -?: — сначала убираем модификатор optional, чтобы иметь доступ к чистому типу.
  2. undefined extends T[K] — если тип значения расширяет undefined (то есть опционален).
  3. ? K : never — если опционален, оставляем ключ, иначе — never.
  4. [keyof T] — собираем все ключи, never исчезает.
Предупреждение: Conditional types могут быстро стать сложными. Если вы чувствуете, что теряетесь — разбейте на более мелкие типы и переиспользуйте их. Читаемость важнее краткости.

10. Template literal types в mapped types

Template literal types — это типы, созданные с помощью синтаксиса шаблонных строк: `prefix${Type}suffix`. Когда их комбинируют с mapped types, появляется возможность создавать новые ключи на основе существующих.

type User = {
  name: string;
  age: number;
  email: string;
};

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<User>;
// Результат:
// {
//   getName: () => string;
//   getAge: () => number;
//   getEmail: () => string;
// }

Здесь используется as — это key remapping (переназначение ключей), введённое в TypeScript 4.1:

  • [K in keyof T as ...] — вместо K используется выражение шаблонной строки.
  • Capitalize<string & K> — встроенная утилита, которая делает первую букву заглавной.
  • Результат: новый ключ getName вместо name.

Ещё пример: тип с событиями для каждого свойства:

type EventMap<T> = {
  [K in keyof T as `${string & K}Changed`]: {
    oldValue: T[K];
    newValue: T[K];
  };
};

type UserEvents = EventMap<User>;
// Результат:
// {
//   nameChanged: { oldValue: string; newValue: string; };
//   ageChanged: { oldValue: number; newValue: number; };
//   emailChanged: { oldValue: string; newValue: string; };
// }

Фильтрация с key remapping:вы можете использовать never в выражении as для удаления ключей:

type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type UserStrings = OnlyStrings<User>;
// Результат:
// {
//   name: string;
//   email: string;
// }

Это более чистый способ фильтрации ключей, чем паттерн с [keyof T], который мы видели ранее.

Аналогия: Template literal types в mapped types — это как конструктор LEGO. Берёте кирпичи (ключи исходного типа), складываете их в шаблон, и получаете новые кирпичи. Или как форматирование строки: "Hello, ${name}!", только на уровне типов.

Интересный факт: Встроенные типы Uppercase<T>, Lowercase<T>, Capitalize<T> и Uncapitalize<T> работают с template literal types и позволяют манипулировать регистром строковых литералов.

11. Практические паттерны — API типы, формы, модели БД

Теперь давайте посмотрим, как mapped types и indexed access types применяются в реальных проектах.

Паттерн 1: Типы для REST API

interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

// Тип для POST-запроса (без id, который генерируется сервером)
type CreateUserDto = Omit<User, 'id'>;

// Тип для PATCH-запроса (все поля опциональны)
type UpdateUserDto = Partial<Omit<User, 'id'>>;

// Тип для ответа (с дополнительными серверными полями)
type UserResponse = User & {
  createdAt: string;
  updatedAt: string;
};

// Тип для списка пользователей (с пагинаацией)
type PaginatedResponse<T> = {
  data: T[];
  total: number;
  page: number;
  limit: number;
};

type UserListResponse = PaginatedResponse<UserResponse>;

Паттерн 2: Типы для форм

interface FormField<T> {
  value: T;
  error?: string;
  touched: boolean;
  valid: boolean;
}

type FormModel<T> = {
  [K in keyof T]: FormField<T[K]>;
};

// Создаём модель формы на основе модели данных
interface UserForm {
  name: string;
  email: string;
  age: number;
}

type UserFormModel = FormModel<UserForm>;
// Результат:
// {
//   name: FormField<string>;
//   email: FormField<string>;
//   age: FormField<number>;
// }

Паттерн 3: Типы для базы данных

interface DatabaseModel {
  id: number;
  createdAt: Date;
  updatedAt: Date;
}

// Тип для создания записи (без серверных полей)
type CreateModel<T extends DatabaseModel> = Omit<T, 'id' | 'createdAt' | 'updatedAt'>;

// Тип для обновления записи (все опционально)
type UpdateModel<T extends DatabaseModel> = Partial<CreateModel<T>>;

// Конкретная модель
interface Product extends DatabaseModel {
  name: string;
  price: number;
  category: string;
}

type CreateProductDto = CreateModel<Product>;
// { name: string; price: number; category: string; }

type UpdateProductDto = UpdateModel<Product>;
// { name?: string; price?: number; category?: string; }

Паттерн 4: Типы для конфигураций

interface DefaultConfig {
  host: string;
  port: number;
  debug: boolean;
  logLevel: 'error' | 'warn' | 'info' | 'debug';
}

type UserConfig = Partial<DefaultConfig>;

function mergeConfig(defaults: DefaultConfig, user: UserConfig): DefaultConfig {
  return { ...defaults, ...user };
}

const config = mergeConfig(
  { host: "localhost", port: 3000, debug: false, logLevel: "info" },
  { port: 8080, debug: true }
);

Паттерн 5: Типы для событий

type EventPayloads = {
  login: { userId: number; timestamp: Date };
  logout: { userId: number };
  purchase: { productId: number; amount: number };
};

type EventHandler<T extends keyof EventPayloads> = {
  (payload: EventPayloads[T]): void;
};

const handleLogin: EventHandler<'login'> = (payload) => {
  console.log(payload.userId); // number
  console.log(payload.timestamp); // Date
};

Эти паттерны показывают, что mapped types и indexed access types — не теоретическая абстракция, а практический инструмент, который используется ежедневно.

12. Реальный пример: утилиты для типобезопасной манипуляции объектами

Давайте создадим набор утилит, которые демонстрируют силу mapped types и indexed access types на практике.

Утилита 1: Глубокое чтение свойства по строковому пути

type DeepGet<T, Path extends string> = Path extends `${infer Head}.${infer Tail}`
  ? Head extends keyof T
    ? DeepGet<T[Head], Tail>
    : never
  : Path extends keyof T
    ? T[Path]
    : never;

interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      user: string;
      password: string;
    };
  };
  server: {
    host: string;
    port: number;
  };
}

type DbHost = DeepGet<Config, 'database.host'>; // string
type DbPassword = DeepGet<Config, 'database.credentials.password'>; // string

Утилита 2: Типизированный pick по значению типа

type PickByValue<T, ValueType> = {
  [K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};

type User = {
  name: string;
  age: number;
  email: string;
  isActive: boolean;
  score: number;
};

type StringFields = PickByValue<User, string>;
// { name: string; email: string; }

type NumberFields = PickByValue<User, number>;
// { age: number; score: number; }

Утилита 3: Очистка undefined/null значений на уровне типов

type Clean<T> = {
  [K in keyof T as T[K] extends undefined | null ? never : K]: T[K] extends object
    ? Clean<T[K]>
    : T[K];
};

type DirtyData = {
  name: string;
  age: number | null;
  email: string | undefined;
  address: {
    city: string;
    zip: string | null;
  };
};

type CleanData = Clean<DirtyData>;
// {
//   name: string;
//   age: number;
//   email: string;
//   address: {
//     city: string;
//     zip: string;
//   };
// }

Утилата 4: Типизированный маппинг значений

type MapValues<T, Map extends Record<keyof T, any>> = {
  [K in keyof T]: K extends keyof Map ? Map[K] : T[K];
};

interface RawData {
  id: string;
  createdAt: string;
  isActive: string;
  score: string;
}

type MappedData = MapValues<RawData, {
  id: number;
  createdAt: Date;
  isActive: boolean;
  score: number;
}>;
// {
//   id: number;
//   createdAt: Date;
//   isActive: boolean;
//   score: number;
// }

Утилита 5: Создание «action creator'ов» для каждого свойства

type ActionCreators<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => {
    type: K;
    payload: T[K];
  };
};

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

type UserActionCreators = ActionCreators<User>;
// {
//   setName: (value: string) => { type: 'name'; payload: string };
//   setAge: (value: number) => { type: 'age'; payload: number };
//   setEmail: (value: string) => { type: 'email'; payload: string };
// }

const actions: UserActionCreators = {
  setName: (value) => ({ type: 'name', payload: value }),
  setAge: (value) => ({ type: 'age', payload: value }),
  setEmail: (value) => ({ type: 'email', payload: value }),
};

Эти утилиты демонстрируют, как mapped types и indexed access types позволяют создавать абстракции, которые работают с любыми типами. Написали один раз — используете везде.

Философия: Mapped types позволяют писать код «один раз для всех типов». Вместо того чтобы создавать 10 утилит для 10 разных типов, вы создаёте одну обобщённую утилиту и применяете её к любому типу. Это принцип DRY (Don't Repeat Yourself), поднятый на уровень типов.

13. Лучшие практики и продвинутые паттерны

1. Ограничивайтеgenerics параметры:

// Плохо: T может быть чем угодно
type BadMapped<T> = {
  [K in keyof T]: T[K];
};

// Хорошо: T должен быть объектом
type GoodMapped<T extends object> = {
  [K in keyof T]: T[K];
};

2. Используйте встроенные типы вместо собственных, когда это возможно:

// Вместо этого:
type MyPartial<T> = { [K in keyof T]?: T[K] };

// Используйте это:
type MyPartial<T> = Partial<T>;

3. Избегайте слишком глубокой вложенности:

// Плохо: 5 уровней вложенности
type DeepNested<T> = {
  [K in keyof T]: {
    [P in keyof T[K]]: {
      [Q in keyof T[K][P]]: T[K][P][Q];
    };
  };
};

// Лучше: рекурсивный тип
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? DeepReadonly<T[K]>
    : T[K];
};

4. Документируйте сложные типы:

/**
 * Создаёт тип, где все строковые свойства T
 * становятся массивами строк
 */
type StringArrayProps<T> = {
  [K in keyof T]: T[K] extends string ? string[] : T[K];
};

5. Используйте mapped types для валидации:

type ValidationSchema<T> = {
  [K in keyof T]: (value: T[K]) => boolean;
};

type UserValidation = ValidationSchema<User>;
// {
//   name: (value: string) => boolean;
//   age: (value: number) => boolean;
//   email: (value: string) => boolean;
// }

const userValidation: UserValidation = {
  name: (v) => v.length > 0,
  age: (v) => v > 0 && v < 150,
  email: (v) => v.includes('@'),
};

6. Объединяйте mapped types для создания «трансформеров»:

type Transformer<T, U> = {
  [K in keyof T]: K extends keyof U ? U[K] : T[K];
};

interface Input {
  a: string;
  b: number;
  c: boolean;
}

interface Transformations {
  a: () => string;
  b: string;
  // c не указан — останется boolean
}

type Output = Transformer<Input, Transformations>;
// {
//   a: () => string;
//   b: string;
//   c: boolean;
// }

7. Помните о производительности: Слишком сложные mapped types могут замедлить компиляцию. Если компилятор «тормозит», упростите типы.

8. Тестируйте типы: Используйте type или interface для проверки:

type Test = AssertEqual<MyType, ExpectedType>;
// Если типы эквивалентны — ошибки нет
// Если нет — компилятор выдаст ошибку
Золотое правило: Если ваш тип занимает больше 5 строк — возможно, стоит разбить его на несколько более простых типов. Читаемость важнее изобретательности.

Итоги

В этом уроке мы подробно изучили два мощных инструмента TypeScript:

Mapped Types

  • Создают новый тип, трансформируя каждое свойство существующего типа
  • Синтаксис: { [K in keyof T]: NewType }
  • Поддерживают модификаторы: readonly, ?
  • Могут удалять модификаторы: -readonly, -?
  • Работают с template literal types и key remapping (as)
  • Встроенные: Partial, Required, Readonly, Pick, Record, Omit

Indexed Access Types

  • Получают тип конкретного свойства из типа объекта: T[K]
  • Работают с union ключей для получения union значений
  • Поддерживают вложенные обращения: T[K][P]
  • Работают с массивами и кортежами
  • Часто комбинируются с mapped types и conditional types

Оба инструмента — основа для создания обобщённых утилит, которые работают с любыми типами. Они позволяют:

  • Автоматизировать создание производных типов
  • Гарантировать типобезопасность при трансформации данных
  • Создавать «машинные» типы, которые генерируют типы на основе входных данных
  • Писать DRY-код на уровне типов

Помните: mapped types — это «конвейер», который перебирает все свойства. Indexed access types — это «пинцет», который достаёт конкретное значение. Вместе они образуют мощную систему для работы с типами.

Практикуйтесь: попробуйте переписать свои ручные type-определения через mapped types. Вы удивитесь, насколько меньше кода получится и насколько легче его поддерживать.

Тест

8 вопросов

Mapped type для опциональных полей

Premium

Key remapping с template literals

Premium