Mapped Types и Indexed Access Types
Отображённые типы и типы с индексным доступом — мощные инструменты для трансформации и навигации по типам в TypeScript
Введение
Представьте, что у вас есть шаблон — например, список покупок. Вы можете взять этот шаблон и автоматически создать его копию, изменив формат каждой записи: вместо списка товаров сделать список цен, вместо цен — список количеств, а вместо количеств — список статусов «куплено/не куплено». При этом сама структура (названия товаров) остаётся прежней.
Именно так работают 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 — это автомат, который берёт ваш паспорт и создаёт новую анкету, копируя все поля, но меняя их правила заполнения (например, делая все поля необязательными).
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<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'
Разберём этот пример:
[K in keyof T]: T[K] extends string ? K : never— для каждого ключа: если значение — строка, вернуть ключ, иначеnever.[keyof T]— indexed access, который собирает все результаты в union.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'
Разберём:
-?:— сначала убираем модификатор optional, чтобы иметь доступ к чистому типу.undefined extends T[K]— если тип значения расширяет undefined (то есть опционален).? K : never— если опционален, оставляем ключ, иначе — never.[keyof T]— собираем все ключи, never исчезает.
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 позволяют создавать абстракции, которые работают с любыми типами. Написали один раз — используете везде.
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>;
// Если типы эквивалентны — ошибки нет
// Если нет — компилятор выдаст ошибку
Итоги
В этом уроке мы подробно изучили два мощных инструмента 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 вопросов