Урок 24: Mapped Types в TypeScript

Мы с вами уже прошли немалый путь, разобрались с базовыми типами, дженериками, интерфейсами и утилитами. Сегодня нас ждет одна из самых элегантных и мощных возможностей TypeScript, это Mapped Types (сопоставленные типы или типы через отображение). Если вы когда-либо мечтали динамически создавать новые типы на основе старых, избегая дублирования кода, то эта тема именно для вас. Это тот инструмент, который выводит вашу работу с типами на совершенно новый уровень профессионализма.

Что такое Mapped Types?

Давайте начнем с фундаментального вопроса, что же такое Mapped Types? Проще говоря, это механизм, который позволяет вам создавать новый тип, итерируясь по ключам (keyof) существующего типа и применяя к каждому ключу некое преобразование. Представьте себе конвейер на фабрике, у вас есть исходный объект (ваш старый тип), который движется по ленте и на каждом этапе к его свойствам применяется определенная операция (сделать свойство только для чтения, сделать его необязательным и т.д.). На выходе с конвейера получается модифицированный объект (новый тип).

Или еще одна аналогия, функция map для массивов в JavaScript. Мы берем массив, применяем к каждому его элементу функцию-трансформер и получаем новый массив. Mapped Types делают то же самое, но не со значениями в массиве, а со свойствами в объектном типе. Это map, но для типов. Эта концепция является краеугольным камнем для многих встроенных утилит TypeScript, таких как Partial<T>Readonly<T>Pick<T, K> и Record<K, V>, с некоторыми из которых мы уже знакомы. Сегодня мы заглянем под капот этих утилит и поймем, как создавать свои собственные, столь же мощные инструменты.

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

Синтаксис Mapped Types может показаться немного непривычным на первый взгляд, но он очень логичен. Взгляните на эту общую формулу:

typescript
[Key in Keys]: Type

Давайте разберем каждую часть этой конструкции по косточкам:

  • Keys: это union-тип (объединение), содержащий строковые литералы или keyof другого типа. Именно по этим ключам будет происходить итерация. Представьте, что это список имен свойств, которые нужно обработать.

  • Key: это переменная, которая по очереди принимает каждое значение из union-типа Keys. Внутри определения типа она представляет собой текущий ключ на данной итерации. Ей можно давать любое имя, но обычно используют P (от Property) или K (от Key).

  • Type: это тип, который будет присвоен каждому свойству с именем Key в новом типе. Самое интересное, что этот тип может вычисляться динамически на основе оригинального типа, часто с использованием еще одной мощной конструкции, Indexed Access Types (T[P]).

Давайте посмотрим на самый простой пример. Допустим, у нас есть тип Person:

typescript
type Person = {
  name: string;
  age: number;
  email: string;
};

Теперь мы хотим создать тип, где все ключи останутся теми же, но все значения станут типа boolean. Mapped Type нам в помощь!

typescript
type PersonBoolean = {
  [P in keyof Person]: boolean;
};

Что произошло? Мы сказали TypeScript: «Пройдись по всем ключам (P) типа Person (keyof Person это 'name' | 'age' | 'email') и для каждого из них в новом типе PersonBoolean определи свойство с этим же именем, но типом boolean«.

В результате тип PersonBoolean будет эквивалентен следующему:

typescript
// Эквивалентный ручной тип
type PersonBoolean = {
  name: boolean;
  age: boolean;
  email: boolean;
};

Но главное преимущество в том, что если мы добавим новое свойство в Person, например, address: string, то тип PersonBoolean автоматически подхватит это изменение и тоже обзаведется свойством address: boolean. Наш Mapped Type является динамическим и поддерживаемым.

Модификаторы: readonly и ? (optional) в Mapped Types

Одна из самых сильных сторон Mapped Types это возможность управлять модификаторами свойств, а именно флагами readonly (только для чтения) и ? (необязательное). Мы можем как добавлять эти модификаторы, так и удалять их, используя операторы + и - (по умолчанию используется +).

Давайте сразу на примерах. Мы можем сделать все свойства оригинала только для чтения:

typescript
type ReadonlyPerson = {
  readonly [P in keyof Person]: Person[P];
};

// Эквивалентно { readonly name: string; readonly age: number; readonly email: string; }

Или сделать все свойства необязательными:

typescript
type PartialPerson = {
  [P in keyof Person]?: Person[P];
};

// Эквивалентно { name?: string; age?: number; email?: string; }

А что, если мы хотим не добавлять, а, наоборот, убрать модификаторы? Например, есть тип с необязательными полями, а нам нужна его версия, где все поля обязательные. Для этого используется оператор -:

typescript
type RequiredPerson = {
  [P in keyof Person]-?: Person[P]; // Заметьте знак минуса перед '?'
};

// Если бы Person был с необязательными полями, RequiredPerson сделал бы их все обязательными.

Точно так же можно снять модификатор readonly:

typescript
type MutablePerson = {
  -readonly [P in keyof Person]: Person[P]; // Снимаем readonly
};

Эта возможность добавлять и удалять модификаторы является основой для встроенных утилит Readonly<T>Partial<T> и Required<T>. Фактически, эти утилиты являются просто удобными алиасами для Mapped Types, которые мы только что написали.

Встроенные утилиты на основе Mapped Types

Теперь, понимая базовый синтаксис, давайте официально познакомимся с тем, как реализованы некоторые из встроенных утилит. Это поможет окончательно закрепить материал.

Readonly<T>

Эта утилита создает тип со всеми свойствами T, установленными в readonly.

typescript
// Реализация Readonly<T> внутри TypeScript (упрощенно)
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

// Применение
type MyReadonlyPerson = Readonly<Person>;
// Теперь все поля MyReadonlyPerson доступны только для чтения.

Partial<T>

Эта утилита создает тип, в котором все свойства T являются необязательными.

typescript
// Реализация Partial<T>
type Partial<T> = {
  [P in keyof T]?: T[P];
};

// Применение
type MyPartialPerson = Partial<Person>;
// Теперь все поля MyPartialPerson не обязательны для заполнения.

Required<T>

Противоположность Partial<T>. Создает тип, в котором все свойства T являются обязательными, даже если в оригинале они были помечены как ?.

typescript
// Реализация Required<T>
type Required<T> = {
  [P in keyof T]-?: T[P];
};

// Пример
type PersonWithOptionalAge = {
  name: string;
  age?: number; // age необязателен
};

type PersonRequired = Required<PersonWithOptionalAge>;
// Теперь в PersonRequired age стал обязательным.

Pick<T, K>

Эта утилита, которую мы уже изучали, также реализована через Mapped Types. Она создает тип, выбирая из типа T только набор свойств, указанный в union-типе K.

typescript
// Реализация Pick<T, K>
type Pick<T, K extends keyof T> = { // Обратите внимание на ограничение extends keyof T
  [P in K]: T[P]; // Итерируемся не по keyof T, а по переданному union-типу K
};

// Применение
type PersonNameAndEmail = Pick<Person, 'name' | 'email'>;
// Эквивалентно { name: string; email: string; }

Record<K, V>

Еще один крайне полезный тип. Он создает объектный тип, ключи которого принадлежат типу K, а значения типу V. Это идеальный способ быстрого описания словаря или карты.

typescript
// Реализация Record<K, V>
type Record<K extends keyof any, V> = { // K может быть любым типом, который может быть ключом (string, number, symbol)
  [P in K]: V;
};

// Применение
type PageUrls = Record<'home' | 'about' | 'contact', string>;
// Эквивалентно { home: string; about: string; contact: string; }

type UserData = Record<string, { name: string; age: number }>;
// Описывает объект, где ключ - любая строка, а значение - объект указанного типа.

Как видите, все эти фундаментальные для TypeScript инструменты построены на идее Mapped Types. Понимая ее, вы перестаете просто использовать волшебные черные ящики и начинаете видеть их внутреннюю механику.

Создание собственных Mapped Types

Теперь давайте перейдем от теории к практике и создадим несколько собственных полезных Mapped Types. Это то, что отличает продвинутого разработчика, умение создавать абстракции, специфичные для вашей предметной области.

Пример 1: Nullable<T>

Создадим тип, который делает все свойства исходного типа T nullable, то есть допускающими null.

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

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

type NullableUser = Nullable<User>;
// Эквивалентно { id: number | null; name: string | null; email: string | null; }

// Использование
const userData: NullableUser = {
  id: 1,
  name: null, // Теперь можно
  email: "max@example.com"
};

Пример 2: Getters<T>

Очень популярный пример. Создадим тип, который для каждого свойства исходного типа создает геттер-функцию.

typescript
type Getters<T> = {
  [P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
  // Обратите внимание на конструкцию `as ...` - это key remapping, о котором мы поговорим дальше.
  // Сейчас достаточно понимания, что мы трансформируем ключ 'name' в ключ 'getName'.
};

interface Car {
  brand: string;
  model: string;
  year: number;
}

type CarGetters = Getters<Car>;
// Эквивалентно {
//   getBrand: () => string;
//   getModel: () => string;
//   getYear: () => number;
// }

Пример 3: SelectiveReadonly<T, K>

А что, если мы хотим сделать свойства только для чтения не для всего типа, а выборочно? Мы можем создать усложненный Mapped Type с дженериками.

typescript
type SelectiveReadonly<T, K extends keyof T> = {
  readonly [P in K]: T[P]; // Сначала делаем readonly только выбранные свойства
} & {
  [P in Exclude<keyof T, K>]: T[P]; // Затем добавляем остальные свойства без изменения
};

// Более элегантный способ с Omit и пересечениями (&)
type SelectiveReadonlyImproved<T, K extends keyof T> = Readonly<Pick<T, K>> & Omit<T, K>;

// Применение
type Person = {
  name: string;
  age: number;
  email: string;
  password: string;
};

type ReadonlySensitiveData = SelectiveReadonlyImproved<Person, 'password' | 'email'>;

const user: ReadonlySensitiveData = {
  name: 'Max',
  age: 30,
  email: 'max@example.com', // readonly!
  password: 'secret'        // readonly!
};

user.name = 'Максим'; // Можно
user.email = 'new@mail.com'; // Ошибка: Cannot assign to 'email' because it is a read-only property.

Этот пример показывает, как Mapped Types можно комбинировать с другими утилитами (PickOmitExclude) и оператором пересечения & для создания невероятно гибких и мощных конструкций.

Key Remapping (сопоставление ключей) с as:

Начиная с TypeScript 4.1, появилась возможность не только менять тип значения свойства, но и динамически изменять сами имена ключей с помощью предложения as внутри Mapped Type. Это открывает просто безграничный простор для творчества.

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

typescript
type NewType = {
  [K in Keys as NewKeyExpression]: Type;
}

Где NewKeyExpression это выражение, которое может трансформировать оригинальный ключ K.

Пример 1: Префиксы и суффиксы

typescript
type WithPrefix<T, Prefix extends string> = {
  [K in keyof T as `${Prefix}${Capitalize<string & K>}`]: T[K];
};

type PersonPrefixed = WithPrefix<Person, 'api'>;
// Эквивалентно { apiName: string; apiAge: number; apiEmail: string; }

Пример 2: Фильтрация ключей

Мы можем использовать as для фильтрации свойств. Если выражение после as вычисляется в never, это свойство исключается из результирующего типа.

typescript
// Создадим тип только для строковых свойств
type StringProperties<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type PersonStringProps = StringProperties<Person>;
// Эквивалентно { name: string; email: string; } (age - number, поэтому исключен)

Пример 3: Кардинальное изменение структуры

Допустим, у нас есть тип, который представляет собой объект с некоторыми значениями, а мы хотим преобразовать его в тип, где ключи это эти значения, а значения оригинальные ключи. Сложно? Смотрите:

typescript
type EventConfig = {
  click: 'onClick';
  hover: 'onHover';
  submit: 'onSubmit';
};

// Инвертируем ключи и значения
type Handlers = {
  [K in keyof EventConfig as EventConfig[K]]: K;
};

// Эквивалентно {
//   onClick: 'click';
//   onHover: 'hover';
//   onSubmit: 'submit';
// }

Key Remapping это мощный инструмент, который позволяет вам практически любым образом трансформировать структуру типов, leading к очень выразительной и точной TypeScript-кодовой базе.

Практические задачи для закрепления материала

Давайте закрепим пройденное, выполнив несколько задач. Попробуйте решить их самостоятельно, прежде чем смотреть решение.

Задача 1: ReadonlyExcept<T, K>

Создайте утилиту ReadonlyExcept<T, K>, которая делает все свойства T доступными только для чтения, кроме тех, что указаны в union-типе K.

typescript
// Ваша реализация здесь...

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type ReadonlyTodo = ReadonlyExcept<Todo, 'title' | 'description'>;

// Должно получиться:
// {
//   readonly title: string; // Исключение - должно остаться изменяемым? Или наоборот?
//   readonly description: string; // Исключение?
//   completed: boolean; // Должно остаться изменяемым?
// }

// Уточнение: Название предполагает "Readonly Except", значит, ВСЕ свойства readonly, КРОМЕ K.
// Поэтому title и description должны НЕ быть readonly, а completed - быть readonly.
const todo: ReadonlyTodo = {
  title: 'Learn TS',
  description: 'Study Mapped Types',
  completed: false
};

todo.title = 'Learn TypeScript'; // Должно быть можно (так как исключение)
todo.completed = true; // Ошибка: должно быть нельзя (так как не исключение)

Решение задачи 1:

typescript
type ReadonlyExcept<T, K extends keyof T> = {
  readonly [P in Exclude<keyof T, K>]: T[P]; // Свойства НЕ из K - readonly
} & {
  [P in K]: T[P]; // Свойства из K - без модификатора readonly
};

// Более читаемо с помощью Omit и Pick:
type ReadonlyExceptImproved<T, K extends keyof T> =
  Readonly<Omit<T, K>> & // Все свойства, кроме K, делаем readonly
  Pick<T, K>;            // Затем добавляем свойства K без изменений

// Использование
type ReadonlyTodo = ReadonlyExceptImproved<Todo, 'title' | 'description'>;

Задача 2: ChangeType<T, NewType>

Создайте утилиту ChangeType<T, NewType>, которая заменяет тип всех свойств исходного типа T на NewType.

typescript
// Ваша реализация здесь...

interface Point {
  x: number;
  y: number;
}

type StringPoint = ChangeType<Point, string>;
// Должно получиться { x: string; y: string; }

Решение задачи 2:

typescript
type ChangeType<T, NewType> = {
  [P in keyof T]: NewType;
};

Задача 3: ExtractPropertyNames<T, PropType>

Создайте утилиту ExtractPropertyNames<T, PropType>, которая возвращает union-тип только тех ключей типа T, тип значений которых совместим с PropType.

typescript
// Ваша реализация здесь...

interface User {
  id: number;
  name: string;
  age: number;
  email: string;
  isAdmin: boolean;
}

type StringKeys = ExtractPropertyNames<User, string>; // Должно быть "name" | "email"
type NumberKeys = ExtractPropertyNames<User, number>; // Должно быть "id" | "age"
type BooleanKeys = ExtractPropertyNames<User, boolean>; // Должно быть "isAdmin"

Решение задачи 3:

typescript
// Это не Mapped Type, который создает объект, а тип, который создает union.
// Мы используем Mapped Type для итерации, а затем получаем его ключи.
type ExtractPropertyNames<T, PropType> = {
  [K in keyof T]: T[K] extends PropType ? K : never;
}[keyof T]; // Важная часть: [keyof T] в конце - это запрос типа по всем ключам

// Как это работает:
// 1. Создается Mapped Type: { id: 'id' | never; name: 'name' | never; ... }
// 2. never поглощается в union-типах, так что получается { id: 'id'; name: 'name'; age: 'age'; email: 'email'; isAdmin: 'isAdmin' | never -> 'isAdmin' }
// 3. [keyof T] в конце означает: мы хотим получить тип всех значений этого объекта.
//    Получится 'id' | 'name' | 'age' | 'email' | 'isAdmin'? Нет!
// 4. Но мы использовали условный тип: если тип свойства не extends PropType, мы подставляем never.
//    Так для StringKeys: { id: never; name: 'name'; age: never; email: 'email'; isAdmin: never }
// 5. Тип значений этого объекта будет never | 'name' | never | 'email' | never, что схлопывается в 'name' | 'email'.

// Альтернативная, более простая для понимания реализация с key remapping:
type ExtractPropertyNamesAlt<T, PropType> = keyof {
  [K in keyof T as T[K] extends PropType ? K : never]: any;
};
// Мы создаем временный тип, где оставляем только нужные ключи (а значения - any),
// а затем получаем его ключи с помощью keyof.

Заключение и дальнейшие шаги

Вы научились не только понимать, как работают встроенные утилиты like Readonly и Partial, но и создавать свои собственные, tailored под конкретные нужды вашего проекта. Вы увидели силу key remapping и научились динамически менять имена ключей. Этот инструмент позволяет вам писать код, который является чрезвычайно сухим, поддерживаемым и типобезопасным на самом высоком уровне.

Удачи в практике и до скорой встречи!

Хотите освоить TypeScript с нуля до уверенного профессионала?
Этот урок является часть моего полного курса TypeScript для начинающих. Переходите по ссылке, чтобы найти оглавление курса, все остальные уроки, дополнительные материалы, практические задания и многое другое!

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий