Форум программистов, компьютерный форум, киберфорум
Reangularity
Войти
Регистрация
Восстановить пароль

Mapped types (отображённые типы) в TypeScript

Запись от Reangularity размещена 03.11.2025 в 20:49
Показов 2197 Комментарии 0
Метки javascript, typescript

Нажмите на изображение для увеличения
Название: Mapped types (отображённые типы) в TypeScript.jpg
Просмотров: 96
Размер:	93.7 Кб
ID:	11363
Mapped types работают как конвейер - берут существующую структуру и производят новую по заданным правилам. Меняют модификаторы свойств, трансформируют значения, фильтруют ключи. Один раз описал правило трансформации - применяешь к любому типу. Звучит просто, но дьявол сидит в мелочах, которые компилятор никогда не простит.

TypeScript предоставляет встроенные утилиты вроде Partial или Required, но они закрывают процентов двадцать реальных задач. Остальное приходится собирать самому. Когда начинаешь копаться глубже, обнаруживаешь целый зоопарк возможностей - условные типы внутри маппингов, ремаппинг ключей через as, рекурсивные трансформации. Причём компилятор не всегда очевидно сообщает об ошибках - можно час искать проблему в трёх строках. Дальше разберём механику работы отображённых типов без воды и просто проговорим грабли на конкретных примерах из реальной практики.

Базовый синтаксис и первые шаги



Отображённый тип выглядит как цикл по ключам объекта. Ключевое слово in перебирает union типов, а слева от него объявляется переменная, хранящая текущий ключ на каждой итерации:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
type SimpleMap<T> = {
  [K in keyof T]: T[K]
}
 
interface User {
  name: string;
  age: number;
}
 
// Результат идентичен исходному типу
type MappedUser = SimpleMap<User>;
// { name: string; age: number; }
На первый взгляд бессмысленная конструкция - зачем дублировать то что уже есть? Но это скелет, на который наращивается функциональность. keyof T возвращает объединение всех ключей типа в виде строковых литералов. Для User получаем "name" | "age". Компилятор поочерёдно подставляет каждое значение из union в переменную K, создавая свойство с соответствующим типом T[K].

Первое боевое применение - изменение модификаторов. В прошлом проекте работал с GraphQL API, где все поля в схеме были nullable. Генератор типов создавал интерфейсы с | null для каждого свойства. На клиенте после валидации эти null становились избыточными - приходилось всюду городить проверки или использовать ненулевое утверждение. Решение заняло три строки:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
type NonNullableFields<T> = {
  [K in keyof T]: NonNullable<T[K]>
}
 
interface ApiUser {
  id: string | null;
  email: string | null;
  role: string | null;
}
 
// Убираем null из всех полей
type CleanUser = NonNullableFields<ApiUser>;
// { id: string; email: string; role: string; }
NonNullable - встроенная утилита TypeScript, вырезающая null и undefined из типа. Применяем её к каждому свойству через маппинг, получаем чистую структуру без потенциальных пустых значений.

Создание простых маппингов



Трансформация значений свойств - базовый кирпичик. Классический пример - оборачивание каждого поля в массив:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Arrayify<T> = {
  [K in keyof T]: Array<T[K]>
}
 
interface Product {
  title: string;
  price: number;
  inStock: boolean;
}
 
type ProductBatch = Arrayify<Product>;
// {
//   title: string[];
//   price: number[];
//   inStock: boolean[];
// }
Использовал такой паттерн в системе фильтрации товаров. Один продукт содержал скалярные значения, но фильтры работали с массивами - пользователь мог выбрать несколько цен, несколько категорий. Вместо дублирования интерфейсов создал производный тип через Arrayify.
Можно усложнить - обернуть значения в Promise для асинхронных операций:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Asyncify<T> = {
  [K in keyof T]: Promise<T[K]>
}
 
interface SyncService {
  fetchUser(): User;
  deletePost(id: string): boolean;
}
 
type AsyncService = Asyncify<SyncService>;
// {
//   fetchUser: Promise<User>;
//   deletePost: Promise<boolean>;
// }
Момент, когда я рефакторил синхронное хранилище в IndexedDB - все методы стали возвращать промисы. Переписывать типы вручную? Нет, один Asyncify решил проблему для семи интерфейсов сервисов.

Модификаторы readonly и optional



TypeScript позволяет добавлять или удалять модификаторы свойств прямо в mapped type. Синтаксис не очевиден - перед модификатором ставится + для добавления или - для удаления. Плюс можно опустить, но я предпочитаю явность:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type AddReadonly<T> = {
  +readonly [K in keyof T]: T[K]
}
 
type RemoveReadonly<T> = {
  -readonly [K in keyof T]: T[K]
}
 
interface MutableConfig {
  apiUrl: string;
  timeout: number;
}
 
// Делаем поля неизменяемыми
type ImmutableConfig = AddReadonly<MutableConfig>;
// {
//   readonly apiUrl: string;
//   readonly timeout: number;
// }
С опциональностью та же история. Модификатор ? после имени свойства делает его необязательным, -? принудительно удаляет опциональность:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type MakeOptional<T> = {
  [K in keyof T]+?: T[K]
}
 
type MakeRequired<T> = {
  [K in keyof T]-?: T[K]
}
 
interface StrictForm {
  username: string;
  password: string;
  email: string;
}
 
// Все поля становятся опциональными
type PartialForm = MakeOptional<StrictForm>;
// {
//   username?: string;
//   password?: string;
//   email?: string;
// }
Встречал кейс в форме регистрации - пошаговый визард с тремя экранами. На первом экране только email обязателен, на втором добавляется password, на третьем - остальные данные. Вместо трёх отдельных интерфейсов описал базовый тип и производные через комбинацию MakeOptional и Pick.

Забавная особенность - порядок применения модификаторов имеет значение. Если сначала добавить readonly, потом попытаться удалить ?, компилятор не ругнётся, но результат может быть неожиданным. Столкнулся с этим при миграции легаси-кода - тип выглядел правильно, но присваивания не работали. Оказалось, модификаторы применялись в неверной последовательности.

Примеры трансформации объектов



Реальные задачи редко решаются одной строкой маппинга. Обычно требуется комбинация техник. Вот пример из системы управления состоянием - нужно было создать тип экшена с полем type, содержащим имя свойства:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type ActionCreators<T> = {
  [K in keyof T]: {
    type: K;
    payload: T[K];
  }
}
 
interface State {
  counter: number;
  user: User | null;
  loading: boolean;
}
 
type Actions = ActionCreators<State>;
// {
//   counter: { type: "counter"; payload: number };
//   user: { type: "user"; payload: User | null };
//   loading: { type: "loading"; payload: boolean };
// }
Каждое свойство превратилось в объект с дискриминантом type и payload соответствующего типа. Раньше писал такие структуры руками, теперь генерирую автоматически.

Другая задача - превращение интерфейса в union геттеров. API возвращал объект с данными, но для реактивности мне нужны были отдельные Observable для каждого поля:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Getters<T> = {
  [K in keyof T]: () => T[K]
}
 
interface AppState {
  theme: 'light' | 'dark';
  language: string;
  notifications: number;
}
 
type StateGetters = Getters<AppState>;
// {
//   theme: () => 'light' | 'dark';
//   language: () => string;
//   notifications: () => number;
// }
Mapped type сгенерировал функциональный интерфейс автоматически. Реализация методов - отдельная история, но типы уже не врут.
Иногда нужна условная логика внутри маппинга - изменить тип только для определённых свойств. Допустим, все строковые поля должны стать массивами строк, остальные оставить без изменений:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type StringsToArrays<T> = {
  [K in keyof T]: T[K] extends string ? string[] : T[K]
}
 
interface Mixed {
  name: string;
  tags: string;
  count: number;
  active: boolean;
}
 
type Transformed = StringsToArrays<Mixed>;
// {
//   name: string[];
//   tags: string[];
//   count: number;
//   active: boolean;
// }
Условный тип extends проверяет, является ли значение свойства строкой. Если да - заменяем на массив, иначе сохраняем исходный тип. Применял это для нормализации данных из CSV - некоторые колонки содержали множественные значения через запятую.

Почему typescript не проверяет типы при использовании спред-оператора?
Никак не могу разобраться, почему не работает проверка типов в следующем примере: interface...

TypeScript vs Script# vs
У кого какой опыт ? - сравнительные достоинства и недостатки.

Перевод C# на TypeScript
Доброго времени суток))) (Извините если не в ту тему) Существует рабочая программы для локального...

VS2012 + typescript 9.1.1
При работе с TypeScript VS2012 виснет или закрывается регулярно, никакой конкретной информации об...


Работа с индексными типами и keyof



Оператор keyof возвращает объединение всех ключей типа, но его реальная сила раскрывается в паре с индексным доступом через квадратные скобки. Синтаксис T[K] извлекает тип значения по ключу K из типа T. Звучит просто, работает хитрее чем кажется.

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface Database {
  users: User[];
  posts: Post[];
  comments: Comment[];
}
 
// Получаем union всех ключей
type TableNames = keyof Database; 
// "users" | "posts" | "comments"
 
// Извлекаем тип конкретной таблицы
type UsersTable = Database["users"]; 
// User[]
 
// Можно использовать union ключей
type AllTables = Database[keyof Database];
// User[] | Post[] | Comment[]
Последняя строка - финт, который я не сразу понял. Когда в квадратных скобках передаёшь union ключей, TypeScript вытаскивает типы всех соответствующих свойств и объединяет их. Это свойство называется дистрибутивностью индексного доступа. Пригодилось при написании универсального репозитория - функция принимала имя коллекции и возвращала соответствующий тип данных без явного switch-case на уровне типов.
Связка keyof и индексного доступа позволяет создавать type-safe функции для работы с объектами. Классический пример - функция получения свойства по имени:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
 
const user = {
  id: 42,
  name: "Alice",
  active: true
};
 
// Компилятор знает точный возвращаемый тип
const userName = getProperty(user, "name"); // string
const userId = getProperty(user, "id"); // number
 
// Ошибка компиляции - такого ключа нет
// const wrong = getProperty(user, "email");
Ограничение K extends keyof T гарантирует, что ключ действительно существует в объекте. Возвращаемый тип T[K] автоматически выводится из структуры объекта. Магии нет - компилятор просто следит чтобы мы не обращались к несуществующим свойствам.
Mapped types активно используют индексный доступ для трансформаций. Помните пример с оборачиванием полей в массивы? Там T[K] внутри маппинга извлекал тип каждого свойства:

TypeScript
1
2
3
type Arrayify<T> = {
  [K in keyof T]: Array<T[K]>
}
Но можно пойти дальше - условно изменять типы на основе извлечённого значения. Допустим, нужно обернуть в Promise только функции, оставив остальные поля без изменений:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type AsyncifyMethods<T> = {
  [K in keyof T]: T[K] extends (...args: any[]) => any
    ? (...args: Parameters<T[K]>) => Promise<ReturnType<T[K]>>
    : T[K]
}
 
interface Service {
  config: { timeout: number };
  fetchUser(id: string): User;
  deletePost(id: number): boolean;
}
 
type AsyncService = AsyncifyMethods<Service>;
// {
//   config: { timeout: number };
//   fetchUser: (id: string) => Promise<User>;
//   deletePost: (id: number) => Promise<boolean>;
// }
Проверка T[K] extends (...args: any[]) => any определяет, является ли свойство функцией. Если да - трансформируем сигнатуру, добавляя Promise к возвращаемому значению. Встроенные утилиты Parameters и ReturnType сохраняют оригинальные параметры и тип результата.
Наткнулся на подводный камень при работе с union типами внутри индексного доступа. Когда свойство может быть нескольких типов, T[K] возвращает их объединение:

TypeScript
1
2
3
4
5
interface Mixed {
  value: string | number;
}
 
type Value = Mixed["value"]; // string | number
Это ожидаемо, но в mapped type может привести к неявному расширению типов. Рефакторил систему кеширования - некоторые ключи содержали union типы, после маппинга производный тип стал более широким чем нужно. Пришлось добавлять дополнительные ограничения через conditional types чтобы сузить результат.
Ещё одна особенность - keyof работает по-разному с разными структурами. Для обычных объектов возвращает строковые литералы, но для массивов и кортежей включает числовые индексы и методы:

TypeScript
1
2
type ArrayKeys = keyof string[]; // number | "length" | "push" | "pop" | ...
type TupleKeys = keyof [string, number]; // "0" | "1" | "length" | "push" | ...
При маппинге кортежей TypeScript автоматически фильтрует служебные ключи, оставляя только индексы элементов. Но если явно использовать ремаппинг через as, фильтрация отключается и приходится обрабатывать все ключи включая методы массива. Это поведение меня укусило при создании универсальных утилит для работы с кортежами - тип распухал до сотен свойств вместо ожидаемых трёх-четырёх.

Встроенные утилиты и их внутренности



TypeScript поставляется с набором предустановленных mapped types, которые решают типовые задачи. Их всего с десяток, но покрывают процентов восемьдесят повседневных сценариев. Посмотрим что внутри - понимание механики поможет создавать собственные аналоги для нестандартных случаев.

Как работают Partial, Required, Readonly



Partial<T> делает все свойства опциональными. Реализация предельно проста - один модификатор добавляет ? к каждому ключу:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Partial<T> = {
  [P in keyof T]?: T[P]
}
 
interface UserProfile {
  name: string;
  email: string;
  avatar: string;
  bio: string;
}
 
// Все поля становятся необязательными
type UpdateProfile = Partial<UserProfile>;
// {
//   name?: string;
//   email?: string;
//   avatar?: string;
//   bio?: string;
// }
Использую Partial в функциях обновления - пользователь может изменить одно поле или все сразу. Альтернатива - писать отдельные интерфейсы для каждой комбинации полей. Звучит как план саморазрушения проекта.

Required<T> делает обратное - удаляет опциональность со всех свойств. Минус перед модификатором принудительно убирает вопросительный знак:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Required<T> = {
  [P in keyof T]-?: T[P]
}
 
interface OptionalConfig {
  host?: string;
  port?: number;
  ssl?: boolean;
}
 
// Теперь все обязательны
type StrictConfig = Required<OptionalConfig>;
// {
//   host: string;
//   port: number;
//   ssl: boolean;
// }
Сталкивался с API, где конфигурация имела значения по умолчанию, но внутри приложения после инициализации все поля гарантированно заполнены. Required позволил описать строгий внутренний тип без дублирования свойств.

Readonly<T> навешивает модификатор неизменяемости на каждое свойство:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Readonly<T> = {
  readonly [P in keyof T]: T[P]
}
 
interface MutableState {
  counter: number;
  items: string[];
}
 
type ImmutableState = Readonly<MutableState>;
// {
//   readonly counter: number;
//   readonly items: string[];
// }
Подвох в том что readonly работает поверхностно. Массив items нельзя переприсвоить, но вызвать push или splice - пожалуйста. Для глубокой иммутабельности нужны рекурсивные типы, до которых доберёмся чуть позже.

Разбор Pick, Omit, Record



Pick<T, K> создаёт новый тип, содержащий только указанные свойства из исходного. Реализация требует ограничения - множество ключей K должно быть подмножеством ключей типа T:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Pick<T, K extends keyof T> = {
  [P in K]: T[P]
}
 
interface Article {
  id: number;
  title: string;
  content: string;
  author: string;
  publishedAt: Date;
  tags: string[];
}
 
// Выбираем только нужные поля
type ArticlePreview = Pick<Article, 'id' | 'title' | 'author'>;
// {
//   id: number;
//   title: string;
//   author: string;
// }
Ограничение K extends keyof T не даёт указать несуществующие ключи - компилятор сразу ругнётся. Применял Pick для разделения DTO на публичную и приватную части. Бэкенд возвращал объект с кучей служебных полей, которые не должны попасть в клиентский стор. Одна строчка отфильтровала ненужное.

Omit<T, K> работает наоборот - исключает указанные свойства. Интересно что его реализация основана на Pick и вспомогательном типе Exclude:

TypeScript
1
2
3
type Exclude<T, U> = T extends U ? never : T;
 
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
Exclude фильтрует union, удаляя из T все типы присутствующие в U. Когда применяем его к keyof T с множеством ключей K, получаем union оставшихся ключей. Затем Pick создаёт объект только с этими ключами.

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface ExtendedUser {
  id: number;
  name: string;
  password: string;
  role: string;
  createdAt: Date;
}
 
// Убираем чувствительные данные
type PublicUser = Omit<ExtendedUser, 'password'>;
// {
//   id: number;
//   name: string;
//   role: string;
//   createdAt: Date;
// }
Обратите внимание на ограничение в Omit - K extends keyof any, а не K extends keyof T как в Pick. Это позволяет указывать ключи которых нет в исходном типе без ошибки компиляции. Странное решение, но иногда полезное - можно написать универсальную функцию фильтрации не зная заранее структуру объекта.

Record<K, T> создаёт объектный тип где все ключи из K имеют значение типа T. Это просто синтаксический сахар над mapped type:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Record<K extends keyof any, T> = {
  [P in K]: T
}
 
// Словарь строк
type StringDict = Record<string, string>;
 
// Енум с описаниями
type StatusDescriptions = Record<'pending' | 'success' | 'error', string>;
// {
//   pending: string;
//   success: string;
//   error: string;
// }
Использовал Record для типизации объектов конфигурации. Когда ключи известны заранее, а значения имеют одинаковый тип, Record читается чище чем ручное объявление интерфейса. Правда есть нюанс - Record<string, T> создаёт индексную сигнатуру, допускающую любые строковые ключи. Если нужен строгий набор ключей, лучше использовать union литералов как во втором примере.

Собственные реализации стандартных утилит



Встроенные утилиты покрывают базовые кейсы, но реальность сложнее. Иногда нужна комбинация нескольких трансформаций или модификация с условием. Написать свою версию - дело десяти минут если понимаешь механику.
Вот DeepReadonly - рекурсивная версия Readonly, которая проходит по всей вложенной структуре:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object
    ? T[P] extends Function
      ? T[P]
      : DeepReadonly<T[P]>
    : T[P]
}
 
interface NestedConfig {
  server: {
    host: string;
    port: number;
    ssl: {
      cert: string;
      key: string;
    }
  };
  cache: {
    ttl: number;
  }
}
 
type ImmutableConfig = DeepReadonly<NestedConfig>;
// Все вложенные объекты становятся readonly
Условная проверка T[P] extends object определяет нужно ли продолжать рекурсию. Дополнительная проверка на Function предотвращает попытку сделать readonly методы объектов - это приводит к ошибкам компиляции.
Другая полезная утилита - PickByType<T, ValueType>, выбирающая только свойства определённого типа:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type PickByType<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K]
}
 
interface MixedData {
  id: number;
  name: string;
  age: number;
  active: boolean;
  email: string;
}
 
// Только числовые поля
type NumericFields = PickByType<MixedData, number>;
// {
//   id: number;
//   age: number;
// }
Ключевой момент - as T[K] extends V ? K : never. Ремаппинг через as позволяет вернуть never для ключей не подходящих под условие. TypeScript автоматически исключает такие свойства из финального типа.

Применял эту утилиту в системе логирования. Нужно было автоматически сериализовать только примитивные поля объекта, игнорируя функции и сложные структуры. PickByType отфильтровал то что не влезает в JSON без явного перечисления ключей.

Встроенные утилиты хороши для простых случаев. Как только задача усложняется - приходится конструировать комбинированные типы или писать с нуля. Главное понимать базовые паттерны, остальное собирается как конструктор.

Продвинутые техники маппинга



Базовые маппинги решают типовые сценарии. Но когда проект разрастается до нескольких сотен типов, появляются задачи которые стандартными средствами не закрыть. Template literal types, ремаппинг ключей и рекурсивные трансформации открывают возможности, о которых создатели языка наверно и не думали.

Нажмите на изображение для увеличения
Название: Mapped types (отображённые типы) в TypeScript 2.jpg
Просмотров: 15
Размер:	71.5 Кб
ID:	11364

Template literal types в связке с маппингом



TypeScript 4.1 добавил шаблонные литеральные типы - конструкции вида prefix_${T}, где T - это тип. В паре с mapped types получается мощный инструмент генерации производных интерфейсов на основе соглашений об именовании.

Классический кейс - создание геттеров и сеттеров. В одном проекте работал с легаси-кодом на классах, где каждое приватное поле имело публичные методы доступа с префиксами get и set:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Getters<T> = {
[K in keyof T & string as `get${Capitalize<K>}`]: () => T[K]
}
 
type Setters<T> = {
[K in keyof T & string as `set${Capitalize<K>}`]: (value: T[K]) => void
}
 
interface State {
counter: number;
username: string;
}
 
type StateGetters = Getters<State>;
// {
//   getCounter: () => number;
//   getUsername: () => string;
// }
 
type StateSetters = Setters<State>;
// {
//   setCounter: (value: number) => void;
//   setUsername: (value: string) => void;
// }
Пересечение keyof T & string отфильтровывает числовые и символьные ключи - template literals работают только со строками. Встроенная утилита Capitalize делает первую букву заглавной. Ремаппинг через as подменяет оригинальное имя свойства на сгенерированное.

Рефакторил REST API клиент с кучей эндпоинтов. Каждый метод возвращал Promise, но нейминг был непоследовательным - где-то fetchUser, где-то getPost, где-то просто articles. Написал утилиту нормализующую имена:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type ApiMethods<T> = {
[K in keyof T & string as `fetch${Capitalize<K>}`]: T[K] extends (...args: infer A) => infer R
  ? (...args: A) => Promise<R>
  : never
}
 
interface RawApi {
user: (id: string) => User;
posts: () => Post[];
comment: (postId: string, commentId: string) => Comment;
}
 
type NormalizedApi = ApiMethods<RawApi>;
// {
//   fetchUser: (id: string) => Promise<User>;
//   fetchPosts: () => Promise<Post[]>;
//   fetchComment: (postId: string, commentId: string) => Promise<Comment>;
// }
Условный тип внутри маппинга проверяет что значение - функция, извлекает параметры и возвращаемый тип, оборачивает результат в Promise. Все методы автоматически получили одинаковый паттерн именования и асинхронную обёртку.
Template literals поддерживают union типы. Подставив union в позицию параметра, TypeScript развернёт все комбинации:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type EventHandlers<T> = {
[K in keyof T & string as `on${Capitalize<K>}${'Start' | 'End'}`]: (data: T[K]) => void
}
 
interface Events {
load: string;
save: number;
}
 
type Handlers = EventHandlers<Events>;
// {
//   onLoadStart: (data: string) => void;
//   onLoadEnd: (data: string) => void;
//   onSaveStart: (data: number) => void;
//   onSaveEnd: (data: number) => void;
// }
Каждый ключ породил два метода - с суффиксом Start и End. Применял это для системы событий где каждая операция имела фазы начала и завершения. Вручную описывать сорок методов не хотелось - маппинг сгенерировал всё автоматически.

Ремаппинг ключей через as



Конструкция as в mapped type даёт контроль над именами свойств результирующего объекта. Можно не только модифицировать имя через template literals, но и полностью его заменить или исключить свойство вернув never.

Писал ORM-обёртку над IndexedDB. Каждая модель имела версионированные поля - при изменении структуры старые поля помечались префиксом _deprecated_. Нужен был тип отфильтровывающий устаревшие свойства:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type RemoveDeprecated<T> = {
[K in keyof T as K extends `_deprecated_${string}` ? never : K]: T[K]
}
 
interface UserModel {
id: string;
name: string;
_deprecated_email: string;
_deprecated_phone: string;
avatar: string;
}
 
type CleanUser = RemoveDeprecated<UserModel>;
// {
//   id: string;
//   name: string;
//   avatar: string;
// }
Условие K extends '_deprecated_${string}' проверяет начинается ли ключ с запрещённого префикса. Если да - возвращаем never, свойство исчезает из результата. Простая проверка сохранила часы ручного труда при миграциях схемы.
Ремаппинг позволяет создавать инвертированные маппинги - менять местами ключи и значения. Это работает если исходные значения - строковые литералы:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Invert<T extends Record<string, string>> = {
[K in keyof T as T[K]]: K
}
 
type HttpCodes = {
ok: '200';
created: '201';
badRequest: '400';
notFound: '404';
};
 
type CodeToName = Invert<HttpCodes>;
// {
//   '200': 'ok';
//   '201': 'created';
//   '400': 'badRequest';
//   '404': 'notFound';
// }
Значение T[K] становится ключом через as, оригинальный ключ K - значением. Использовал это для двустороннего маппинга статусов в бизнес-логике. API возвращал коды ответов, внутри приложения удобнее работать с человекочитаемыми именами. Вместо двух ручных определений - один объект и автоматическая инверсия.

Рекурсивные mapped types и глубокая трансформация структур



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

Вот DeepPartial - делает опциональными все свойства включая вложенные:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
  ? T[P] extends Array<infer U>
    ? Array<DeepPartial<U>>
    : T[P] extends Function
      ? T[P]
      : DeepPartial<T[P]>
  : T[P]
}
 
interface Config {
database: {
  host: string;
  credentials: {
    user: string;
    password: string;
  }
};
cache: {
  ttl: number;
  strategy: 'LRU' | 'LFU';
}
}
 
type PartialConfig = DeepPartial<Config>;
// Все уровни вложенности становятся опциональными
Логика проверяет тип каждого свойства. Если это объект - вызываем DeepPartial рекурсивно. Массивы требуют особой обработки - извлекаем тип элементов через infer, применяем трансформацию, оборачиваем обратно в массив. Функции оставляем без изменений - сделать метод опциональным обычно не имеет смысла.
Наткнулся на проблему с циклическими структурами. Допустим, есть граф где узел содержит ссылки на другие узлы:

TypeScript
1
2
3
4
interface Node {
id: string;
children: Node[];
}
Попытка применить DeepPartial приводит к бесконечной рекурсии на уровне типов. TypeScript 3.7 добавил защиту от этого - компилятор останавливается на определённой глубине с ошибкой "Type instantiation is excessively deep". Решение - добавить параметр ограничивающий глубину рекурсии:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
type DeepPartialWithDepth<T, Depth extends number = 10> = 
Depth extends 0
  ? T
  : {
      [P in keyof T]?: T[P] extends object
        ? DeepPartialWithDepth<T[P], Prev<Depth>>
        : T[P]
    }
 
// Вспомогательный тип для декремента
type Prev<T extends number> = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9][T];
Каждый уровень рекурсии уменьшает счётчик глубины. При достижении нуля трансформация прекращается. Хак с Prev использует индексный доступ к кортежу для симуляции вычитания - TypeScript не поддерживает арифметику на уровне типов напрямую.
Другая задача - глубокое преобразование всех дат в строки для сериализации в JSON:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type DeepSerialize<T> = T extends Date
? string
: T extends Array<infer U>
  ? Array<DeepSerialize<U>>
  : T extends object
    ? { [K in keyof T]: DeepSerialize<T[K]> }
    : T
 
interface Event {
id: number;
createdAt: Date;
metadata: {
  timestamp: Date;
  tags: string[];
}
}
 
type SerializedEvent = DeepSerialize<Event>;
// {
//   id: number;
//   createdAt: string;
//   metadata: {
//     timestamp: string;
//     tags: string[];
//   }
// }
Первая проверка ловит объекты Date и заменяет их строкой. Остальные объекты обрабатываются рекурсивно. Порядок условий важен - Date является object, поэтому его нужно проверять раньше общей проверки на объектность.

Фильтрация свойств по условию



Иногда нужно не трансформировать тип а вырезать из него часть свойств по определённому критерию. Комбинация условных типов и ремаппинга через as даёт такую возможность без написания явных интерфейсов.
У меня был случай с формой редактирования пользователя. Модель содержала поля доступные только для чтения - id, даты создания и обновления. Редактировать их нельзя, но исключать вручную из типа формы - лишняя работа:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type EditableFields<T> = {
[K in keyof T as T[K] extends Function ? never : K]: T[K]
}
 
type RemoveReadonly<T> = {
[K in keyof T as IfEquals<
  { [Q in K]: T[K] },
  { -readonly [Q in K]: T[K] },
  never,
  K
>]: T[K]
}
 
// Вспомогательный тип для сравнения
type IfEquals<X, Y, A = X, B = never> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? A : B;
RemoveReadonly использует хитрость - создаёт два объекта с одним свойством, один с оригинальным модификатором, другой с явно убранным. Если типы эквивалентны - свойство не было readonly, возвращаем его. Иначе - исключаем через never. Детектирование readonly не тривиально потому что модификатор не влияет на assignability в обычном смысле. Нужен строгий тест на эквивалентность типов, который работает через трюк с generic функциями - такой подход единственный надёжный способ различить типы отличающиеся только модификаторами.
Другой сценарий - фильтрация по типу значения. Нужны только поля содержащие функции для создания обёртки:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type FunctionProperties<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K]
}
 
interface Service {
config: { timeout: number };
fetchData: () => Promise<string>;
processData: (data: string) => number;
metadata: Map<string, any>;
}
 
type ServiceMethods = FunctionProperties<Service>;
// {
//   fetchData: () => Promise<string>;
//   processData: (data: string) => number;
// }
Проверка T[K] extends Function отбирает только функциональные свойства. Остальные превращаются в never и исчезают. Использовал это для автоматической генерации прокси-объектов - нужно было оборачивать все методы сервиса в try-catch с логированием, но данные оставлять нетронутыми.
Можно пойти дальше и комбинировать несколько условий. Понадобилось выбрать свойства которые одновременно не readonly и не опциональны - только мутабельные обязательные поля:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type MutableRequired<T> = {
[K in keyof T as IfEquals<
  { [Q in K]: T[K] },
  { -readonly [Q in K]-?: T[K] },
  K,
  never
>]: T[K]
}
 
interface DataModel {
readonly id: string;
name?: string;
readonly createdAt: Date;
email: string;
age: number;
}
 
type EditableRequired = MutableRequired<DataModel>;
// {
//   email: string;
//   age: number;
// }
Двойная проверка отсеивает readonly через -readonly и опциональные через -?. Только поля без модификаторов проходят тест на эквивалентность. Применял это в построителе SQL-запросов - только такие поля можно было использовать в WHERE без дополнительных проверок на null.

Создание универсальной утилиты Extract для вложенных объектов



Работая с глубоко вложенными структурами, часто нужно достать тип конкретного свойства по пути. TypeScript даёт доступ через цепочку индексов T['a']['b']['c'], но это не гибко - путь жёстко закодирован в типе.
Написал универсальную утилиту принимающую путь как массив ключей:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
type DeepPick<T, Path extends readonly (string | number)[]> =
Path extends readonly [infer First, ...infer Rest]
  ? First extends keyof T
    ? Rest extends readonly (string | number)[]
      ? DeepPick<T[First], Rest>
      : T[First]
    : never
  : T;
 
interface AppState {
user: {
  profile: {
    name: string;
    age: number;
  };
  settings: {
    theme: 'light' | 'dark';
    notifications: boolean;
  }
};
posts: Array<{
  id: number;
  title: string;
}>;
}
 
// Извлекаем тип по пути
type UserName = DeepPick<AppState, ['user', 'profile', 'name']>;
// string
 
type Theme = DeepPick<AppState, ['user', 'settings', 'theme']>;
// 'light' | 'dark'
Рекурсивный тип разбирает массив пути. На каждой итерации извлекаем первый элемент через infer First, проверяем что он существует как ключ текущего типа, рекурсивно обрабатываем оставшийся путь. Когда путь заканчивается - возвращаем текущий тип.
Проблема с такой реализацией - TypeScript не может вывести literal типы из обычных строк. Приходится явно аннотировать путь через as const:

TypeScript
1
2
3
4
5
6
7
// Не работает - путь выводится как string[]
const path = ['user', 'profile', 'name'];
type Wrong = DeepPick<AppState, typeof path>;
 
// Работает - путь выводится как ['user', 'profile', 'name']
const path = ['user', 'profile', 'name'] as const;
type Correct = DeepPick<AppState, typeof path>;
Использовал DeepPick в state manager'е. Селекторы принимали путь к вложенному состоянию и возвращали правильный тип без явного кастинга. Ошибку в пути компилятор ловил сразу - несуществующий ключ давал never, и дальше всё рассыпалось с понятным сообщением.
Можно расширить идею - создать утилиту собирающую несколько свойств по разным путям:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
type DeepPickMultiple<T, Paths extends Record<string, readonly (string | number)[]>> = {
[K in keyof Paths]: DeepPick<T, Paths[K]>
}
 
type SelectedFields = DeepPickMultiple<AppState, {
name: ['user', 'profile', 'name'];
theme: ['user', 'settings', 'theme'];
}>;
// {
//   name: string;
//   theme: 'light' | 'dark';
// }
Mapped type итерируется по объекту с путями, применяя DeepPick к каждому значению. Получается плоский объект с переименованными свойствами из разных уровней вложенности исходной структуры. Рефакторил код работы с deeply nested API responses. Бэкенд отдавал монструозные JSON'ы с семью уровнями вложенности. Вместо создания промежуточных интерфейсов для каждого уровня, описал только корневой тип и пути к нужным полям. DeepPickMultiple сгенерировал плоскую структуру для компонентов автоматически. Натолкнулся на ограничение - TypeScript имеет лимит глубины рекурсии типов. Для путей длиннее пятнадцати элементов компилятор выдаёт ошибку. Пришлось добавить защиту:

TypeScript
1
2
3
4
5
6
7
8
9
10
type DeepPickSafe<T, Path extends readonly any[], Depth extends number = 15> =
Depth extends 0
  ? unknown
  : Path extends readonly [infer First, ...infer Rest]
    ? First extends keyof T
      ? Rest extends readonly any[]
        ? DeepPickSafe<T[First], Rest, Prev<Depth>>
        : T[First]
      : never
    : T;
При превышении лимита тип становится unknown, что заставляет явно указать структуру. Лучше чем cryptic ошибка компилятора про "excessive depth".
Эта техника работает и в обратную сторону - установка значения по пути. Нужна утилита создающая тип для функции обновления вложенного поля:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
type DeepUpdate<T, Path extends readonly (string | number)[], Value> =
Path extends readonly [infer First, ...infer Rest]
  ? First extends keyof T
    ? Rest extends readonly (string | number)[]
      ? Rest['length'] extends 0
        ? { [K in keyof T]: K extends First ? Value : T[K] }
        : { [K in keyof T]: K extends First ? DeepUpdate<T[K], Rest, Value> : T[K] }
      : never
    : never
  : T;
 
type UpdatedState = DeepUpdate<AppState, ['user', 'settings', 'theme'], 'custom'>;
// Поле user.settings.theme становится 'custom' вместо 'light' | 'dark'
Mapped type на каждом уровне либо заменяет конечное поле новым типом, либо рекурсивно обрабатывает вложенную структуру. Использовал это для типизации reducers в кастомном стейт-менеджере - каждый экшен обновлял конкретное поле по пути, тип результата выводился автоматически.

Продвинутые mapped types превращают систему типов TypeScript в почти полноценный язык метапрограммирования. Да, есть ограничения - рекурсия лимитирована, вычисления идут на этапе компиляции, дебажить сложные типы больно. Но альтернатива - километры повторяющихся определений и постоянные ошибки при рефакторинге. Лучше потратить час на написание универсальной утилиты чем месяц на ручную синхронизацию типов.

Типичные ловушки и грабли



Mapped types выглядят элегантно в документации. В продакшене всё сложнее - компилятор выдаёт криптичные ошибки, производительность проверки типов падает в десятки раз, а иногда код который должен работать просто не компилируется без видимых причин. За пять лет работы с TypeScript я наступил на все возможные грабли, некоторые неоднократно.

Проблемы с вариантностью



Вариантность определяет как подтипы связаны с их контейнерами. TypeScript использует структурную типизацию, а mapped types могут неожиданно изменить ковариантность или контравариантность свойств. Это проявляется когда пытаешься присвоить один mapped type другому.
Классический пример - преобразование методов в свойства с функциями. Казалось бы эквивалентные типы, но компилятор не согласен:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface Methods {
  getValue(): string;
}
 
type Properties = {
  [K in keyof Methods]: Methods[K]
}
 
// Ошибка: методы и свойства-функции имеют разную вариантность
const props: Properties = {
  getValue: () => "test"
};
 
const methods: Methods = props; // Type error!
Метод getValue() в интерфейсе ковариантен по возвращаемому значению. Свойство getValue которое хранит функцию - инвариантно, потому что может быть переприсвоено. TypeScript запрещает такое преобразование для безопасности. Наткнулся на это рефакторя Observable-библиотеку. Mapped type генерировал обёртки вокруг методов, но присвоить результат в интерфейс с методами не получалось. Решение - закастить тип или переписать исходный интерфейс с функциями вместо методов. Первое опасно, второе требует изменения API.

Другая засада - contravariance позиций параметров. Mapped type может случайно сделать параметры ковариантными:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
type HandlerMap<T> = {
  [K in keyof T]: (value: T[K]) => void
}
 
interface Events {
  click: MouseEvent;
  input: string;
}
 
// Параметры становятся ковариантными вместо контравариантных
type Handlers = HandlerMap<Events>;
Это ломает принцип подстановки Барбары Лисков. Если создать подтип Events с более узкими типами событий, Handlers не будет их корректно обрабатывать. Пришлось добавлять явные проверки вариантности через utility types со строгими generic constraints.

Циклические зависимости типов



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

TypeScript
1
2
3
4
5
6
7
8
9
10
11
type Recursive<T> = {
  [K in keyof T]: Recursive<T[K]>
}
 
interface Node {
  value: string;
  next: Node; // Циклическая структура
}
 
// Ошибка появится не сразу, а при попытке использовать
type RecursiveNode = Recursive<Node>;
Компилятор долго пытается развернуть тип, потом сдаётся с сообщением "Type instantiation is excessively deep and possibly infinite". Первые несколько раз я не понимал в чём проблема - код выглядел валидным.
Работал с графом зависимостей компонентов React. Каждый компонент содержал ссылки на детей, дети ссылались на родителей. Mapped type для трансформации пропсов входил в бесконечную рекурсию. Решение - ограничить глубину обработки параметром и явно прерывать рекурсию на определённом уровне:

TypeScript
1
2
3
4
5
6
7
8
9
10
type SafeRecursive<T, Depth extends number = 5> = 
  Depth extends 0 
    ? T 
    : T extends object
      ? { [K in keyof T]: SafeRecursive<T[K], Prev<Depth>> }
      : T
 
// Счётчик глубины
type Prev<N extends number> = N extends 0 ? 0 : 
  [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10][N];
Ограничение на пять уровней решило проблему для большинства случаев. Но приходилось помнить - если кто-то вложит структуру глубже, тип просто вернёт исходный T без трансформации. Не идеально, зато компилируется.

Производительность компилятора



Сложные mapped types убивают скорость type checking. В одном проекте время компиляции выросло с двух секунд до двух минут после добавления глубоких рекурсивных трансформаций. IDE начал подвисать при автодополнении. TypeScript проверяет типы structural matching - сравнивает буквально каждое свойство каждого объекта. Mapped type генерирующий сотни свойств создаёт комбинаторный взрыв проверок. Добавьте сюда union types и generic constraints - получите экспоненциальную сложность.

Несколько правил для ускорения:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Медленно - компилятор развернёт весь union
type AllKeys<T> = T extends any ? keyof T : never;
 
// Быстрее - используем distributive хитрость
type FastKeys<T> = keyof T;
 
// Очень медленно - вложенные mapped types
type DeepTransform<T> = {
  [K in keyof T]: T[K] extends object
    ? { [P in keyof T[K]]: Transform<T[K][P]> }
    : T[K]
}
 
// Быстрее - разделяем логику
type TransformValue<V> = V extends object ? TransformObject<V> : V;
type TransformObject<T> = { [K in keyof T]: TransformValue<T[K]> };
Избавление от вложенности и использование вспомогательных типов снизило время компиляции втрое. IDE перестал тормозить при hover над переменными.

Производительность runtime и tree-shaking



Mapped types существуют только на этапе компиляции, но косвенно влияют на runtime. Если тип слишком абстрактный, bundler не сможет определить какой код реально используется и включит всё в сборку.
Писал библиотеку с плагинами. Каждый плагин регистрировал методы через mapped type:

TypeScript
1
2
3
4
5
type PluginAPI<T> = {
  [K in keyof T]: T[K] extends (...args: infer A) => infer R
    ? (...args: A) => Promise<R>
    : never
}
Тип выглядел невинно, но в runtime все плагины импортировались даже если использовался только один. Webpack не смог статически определить какие методы нужны - mapped type размыл границы зависимостей.
Решение - разделить типы и реализацию. Создал explicit interfaces для каждого плагина, mapped type использовал только для генерации документации:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
// Явный интерфейс для tree-shaking
interface AuthPlugin {
  login(credentials: Credentials): Promise<Token>;
  logout(): Promise<void>;
}
 
// Mapped type только для docs generation
type PluginDocs<T> = {
  [K in keyof T]: T[K] extends (...args: infer A) => infer R
    ? { args: A; returns: R }
    : never
}
Bundle size упал на 40% - bundler начал видеть прямые импорты и удалять неиспользуемый код.
Другая проблема - type guards при работе с mapped types требуют runtime проверок. TypeScript не делает это автоматически:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
type Optional<T> = {
  [K in keyof T]?: T[K]
}
 
function processData<T>(data: Optional<T>) {
  // Каждое свойство может быть undefined
  // Нужны runtime проверки которые дублируют type checking
  if (data.field !== undefined) {
    console.log(data.field.toString());
  }
}
Дублирование логики проверок раздувает код. В одном компоненте React пятнадцать защитных if проверяли существование полей потому что mapped type сделал всё опциональным. Рефакторинг с использованием Required на критических участках сократил код и убрал лишние ветвления.

Mapped types - мощный инструмент, но необходимо понимать компромиссы. Простота на уровне типов оборачивается сложностью на этапе компиляции или runtime. Золотое правило - использовать ровно столько магии типов сколько реально необходимо, не больше.

Система валидации форм с динамической типизацией



Три месяца назад переписывал форму оформления кредита в банковском приложении. Сорок полей, двадцать правил валидации, трансформации данных между API и UI. Классический подход - написать интерфейс формы, интерфейс ошибок, интерфейс для API, кучу валидаторов вручную. Потом добавили новое поле - изменения в пяти местах, забыл про одно, продакшен упал. Решил автоматизировать через mapped types. Схему формы описываешь один раз, всё остальное генерируется. Валидаторы типобезопасны, ошибки привязаны к конкретным полям, трансформеры между слоями работают без кастов. Получилось компактнее и надёжнее чем предыдущие пять попыток разных людей написать "универсальную систему форм". Архитектура построена вокруг центральной схемы - объекта описывающего поля формы с их типами и правилами. Из схемы через mapped types выводятся все производные типы - значения полей, ошибки валидации, состояние формы, типы для API. Один источник правды вместо рассинхронизированного зоопарка интерфейсов.

Архитектура и обоснование решений



Базовая идея - схема формы как generic объект где ключ это имя поля, а значение описывает его характеристики:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
interface FieldDescriptor<T> {
  type: T;
  required?: boolean;
  default?: T;
  transform?: {
    toApi?: (value: T) => any;
    fromApi?: (value: any) => T;
  };
}
 
type FormSchema = Record<string, FieldDescriptor<any>>;
Из этой схемы mapped types извлекают типы значений полей. Conditional types обрабатывают опциональность в зависимости от флага required:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
type FormValues<S extends FormSchema> = {
  [K in keyof S]: S[K]['required'] extends true 
    ? S[K]['type']
    : S[K]['type'] | undefined
};
 
type FormErrors<S extends FormSchema> = {
  [K in keyof S]?: string
};
 
// Пример использования
const userFormSchema = {
  email: {
    type: '' as string,
    required: true,
  },
  age: {
    type: 0 as number,
    required: true,
  },
  bio: {
    type: '' as string,
    required: false,
    default: '',
  },
} as const;
 
type UserValues = FormValues<typeof userFormSchema>;
// {
//   email: string;
//   age: number;
//   bio: string | undefined;
// }
Хак с '' as string нужен чтобы TypeScript вывел примитивный тип вместо литерала. Можно было использовать отдельное поле в дескрипторе, но так компактнее.

Обоснование архитектуры - централизация. Схема живёт в одном месте, изменения автоматически проп агируются во все производные типы. Добавил поле - валидаторы, ошибки, API-типы обновились сами. Убрал поле - компилятор сразу покажет где код сломался. Альтернативный подход который пробовал - JSON Schema с генерацией типов через codegen. Работает, но требует отдельного шага сборки и теряет связь между схемой и кодом. Изменил JSON - надо пересобрать проект чтобы увидеть ошибки типов. С mapped types рефакторинг мгновенный.

Реализация типобезопасных валидаторов



Валидатор принимает значение и возвращает либо undefined (валидация прошла), либо текст ошибки. Тип валидатора должен соответствовать типу поля - нельзя применить валидатор строк к числу:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type Validator<T> = (value: T) => string | undefined;
 
type FormValidators<S extends FormSchema> = {
  [K in keyof S]?: Validator<S[K]['type']>[]
};
 
// Встроенные валидаторы с type inference
const required = <T>(message = 'Обязательное поле'): Validator<T | undefined> => 
  (value) => value === undefined || value === '' ? message : undefined;
 
const minLength = (min: number, message?: string): Validator<string> => 
  (value) => value.length < min 
    ? message || `Минимум ${min} символов`
    : undefined;
 
const email = (message = 'Невалидный email'): Validator<string> => 
  (value) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? message : undefined;
 
const range = (min: number, max: number): Validator<number> => 
  (value) => value < min || value > max 
    ? `Значение должно быть между ${min} и ${max}`
    : undefined;
 
// Использование
const validators: FormValidators<typeof userFormSchema> = {
  email: [required(), email()],
  age: [required(), range(18, 120)],
  bio: [minLength(10, 'Напишите хотя бы 10 символов о себе')],
};
Каждый валидатор типизирован под конкретный тип данных. Попытка применить minLength к числовому полю вызовет ошибку компиляции. Массив валидаторов позволяет комбинировать проверки - сначала проверяем на пустоту, потом валидируем формат.
Движок валидации перебирает все валидаторы для каждого поля и собирает ошибки:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function validateForm<S extends FormSchema>(
  values: Partial<FormValues<S>>,
  validators: FormValidators<S>
): FormErrors<S> {
  const errors: FormErrors<S> = {};
  
  // Итерируемся по ключам схемы
  for (const key in validators) {
    const fieldValidators = validators[key];
    if (!fieldValidators) continue;
    
    const value = values[key];
    
    // Применяем все валидаторы последовательно
    for (const validator of fieldValidators) {
      const error = validator(value as any);
      if (error) {
        errors[key] = error;
        break; // Первая ошибка останавливает проверку поля
      }
    }
  }
  
  return errors;
}
Каст as any неизбежен - TypeScript не может гарантировать что runtime значение соответствует типу схемы. Но это единственное место где теряем типобезопасность, весь остальной код строго типизирован.
Валидаторы можно композировать. Написал утилиту создающую комбинированный валидатор из нескольких:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function combineValidators<T>(...validators: Validator<T>[]): Validator<T> {
  return (value) => {
    for (const validator of validators) {
      const error = validator(value);
      if (error) return error;
    }
    return undefined;
  };
}
 
// Комплексная проверка пароля
const passwordValidator = combineValidators<string>(
  required('Пароль обязателен'),
  minLength(8, 'Минимум 8 символов'),
  (value) => !/[A-Z]/.test(value) ? 'Нужна хотя бы одна заглавная буква' : undefined,
  (value) => !/[0-9]/.test(value) ? 'Нужна хотя бы одна цифра' : undefined,
);
Встраивал это в production формы. Работает стабильно, ошибки понятные, рефакторинг безболезненный. Добавление нового правила валидации занимает две минуты вместо получаса правки в трёх файлах.

Типобезопасные трансформеры данных между слоями приложения



Частая проблема - форма работает с одними типами, API ожидает другие. Даты приходят строками, числа как строки, вложенные объекты нужно уплощать или наоборот. Писать конвертеры вручную - гарантированно забудешь обновить при изменении схемы. Трансформеры описываются в схеме, применяются автоматически:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
type ApiValues<S extends FormSchema> = {
  [K in keyof S]: S[K]['transform'] extends { toApi: (value: S[K]['type']) => infer R }
    ? R
    : S[K]['type']
};
 
// Схема с трансформацией дат
const eventFormSchema = {
  title: {
    type: '' as string,
    required: true,
  },
  date: {
    type: new Date() as Date,
    required: true,
    transform: {
      toApi: (d: Date) => d.toISOString(),
      fromApi: (s: string) => new Date(s),
    },
  },
  participants: {
    type: 0 as number,
    required: true,
    transform: {
      toApi: (n: number) => String(n), // API хочет строку
      fromApi: (s: string) => parseInt(s, 10),
    },
  },
} as const;
 
type EventApiData = ApiValues<typeof eventFormSchema>;
// {
//   title: string;
//   date: string;  // Трансформирована в ISO строку
//   participants: string;  // Число стало строкой
// }
Conditional type проверяет наличие функции toApi в дескрипторе. Если есть - извлекает её возвращаемый тип через infer, иначе оставляет исходный тип поля. Тип API автоматически отражает все трансформации.
Функции применения трансформаций:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
function toApiFormat<S extends FormSchema>(
  values: FormValues<S>,
  schema: S
): ApiValues<S> {
  const result: any = {};
  
  for (const key in schema) {
    const descriptor = schema[key];
    const value = values[key];
    
    result[key] = descriptor.transform?.toApi 
      ? descriptor.transform.toApi(value)
      : value;
  }
  
  return result;
}
 
function fromApiFormat<S extends FormSchema>(
  data: ApiValues<S>,
  schema: S
): FormValues<S> {
  const result: any = {};
  
  for (const key in schema) {
    const descriptor = schema[key];
    const value = data[key as keyof typeof data];
    
    result[key] = descriptor.transform?.fromApi
      ? descriptor.transform.fromApi(value)
      : value;
  }
  
  return result;
}
Применение в реальном коде:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
async function submitForm<S extends FormSchema>(
  values: FormValues<S>,
  schema: S,
  endpoint: string
) {
  const apiData = toApiFormat(values, schema);
  const response = await fetch(endpoint, {
    method: 'POST',
    body: JSON.stringify(apiData),
  });
  const responseData = await response.json();
  return fromApiFormat(responseData, schema);
}
Одна функция обрабатывает любую форму. Типы проверяются на этапе компиляции - если добавить поле в схему но забыть обработать в трансформере, компилятор ругнётся. В старой кодовой базе такие баги находились только в production когда пользователь получал 500 ошибку.

Генерация схем на основе mapped types



Описывать схему вручную работает для простых форм. Когда полей больше тридцати, появляется соблазн сгенерировать схему из существующего типа. Допустим, есть интерфейс модели из ORM или API клиента - хочется переиспользовать его для формы без дублирования.
Написал утилиту которая берёт произвольный тип и строит из него схему:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type InferSchemaFromType<T> = {
  [K in keyof T]-?: {
    type: T[K];
    required: undefined extends T[K] ? false : true;
  }
};
 
interface UserModel {
  id: string;
  email: string;
  name?: string;
  age: number;
  verified: boolean;
}
 
type GeneratedSchema = InferSchemaFromType<UserModel>;
// {
//   id: { type: string; required: true };
//   email: { type: string; required: true };
//   name: { type: string | undefined; required: false };
//   age: { type: number; required: true };
//   verified: { type: boolean; required: true };
// }
Модификатор -? принудительно делает все свойства обязательными на уровне схемы - это важно потому что сам дескриптор не должен быть опциональным, опциональность хранится во флаге required. Conditional type undefined extends T[K] определяет был ли тип изначально опциональным.

Проблема в том что сгенерированная схема не содержит дефолтных значений и трансформеров. Пришлось добавить способ обогащения схемы:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type EnrichSchema<S, Enrichment> = {
  [K in keyof S]: K extends keyof Enrichment
    ? S[K] & Enrichment[K]
    : S[K]
};
 
const baseSchema = {} as InferSchemaFromType<UserModel>;
 
const enrichedSchema = {
  ...baseSchema,
  age: {
    ...baseSchema.age,
    default: 18,
    transform: {
      toApi: (n: number) => String(n),
      fromApi: (s: string) => parseInt(s, 10),
    },
  },
  verified: {
    ...baseSchema.verified,
    default: false,
  },
} as const;
Spread-оператор сохраняет исходные свойства но позволяет переопределить нужные. Типы остаются строгими - попытка добавить трансформер с неправильной сигнатурой вызовет ошибку.

В банковском проекте использовал обратное преобразование - из схемы формы генерировал TypeScript интерфейс для документации. Запускал codegen script который парсил схемы через TypeScript Compiler API и создавал markdown с описанием полей. Mapped types дали единый источник правды - схема в коде и документация всегда синхронизированы.

Интеграция с React и тестирование



Типобезопасная схема бесполезна если интеграция с UI фреймворком теряет типы. React хук для работы с формой должен сохранить все преимущества mapped types:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
function useTypedForm<S extends FormSchema>(
  schema: S,
  validators: FormValidators<S>,
  initialValues?: Partial<FormValues<S>>
) {
  const [values, setValues] = useState<Partial<FormValues<S>>>(() => {
    // Заполняем дефолтные значения из схемы
    const defaults: any = {};
    for (const key in schema) {
      if (schema[key].default !== undefined) {
        defaults[key] = schema[key].default;
      }
    }
    return { ...defaults, ...initialValues };
  });
  
  const [errors, setErrors] = useState<FormErrors<S>>({});
  const [touched, setTouched] = useState<Partial<Record<keyof S, boolean>>>({});
 
  // Изменение значения с валидацией
  const setValue = useCallback(<K extends keyof S>(
    field: K,
    value: FormValues<S>[K]
  ) => {
    setValues(prev => ({ ...prev, [field]: value }));
    
    // Валидируем только изменённое поле
    const fieldValidators = validators[field];
    if (fieldValidators && touched[field]) {
      const error = fieldValidators
        .map(v => v(value as any))
        .find(e => e !== undefined);
      setErrors(prev => ({ ...prev, [field]: error }));
    }
  }, [validators, touched]);
 
  // Отметить поле как тронутое
  const setFieldTouched = useCallback((field: keyof S) => {
    setTouched(prev => ({ ...prev, [field]: true }));
  }, []);
 
  // Валидация всей формы
  const validate = useCallback(() => {
    const newErrors = validateForm(values, validators);
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  }, [values, validators]);
 
  // Сброс формы
  const reset = useCallback(() => {
    setValues({});
    setErrors({});
    setTouched({});
  }, []);
 
  return {
    values,
    errors,
    touched,
    setValue,
    setFieldTouched,
    validate,
    reset,
  };
}
Дженерик параметр хука привязан к схеме. setValue принимает только существующие ключи формы, тип значения выводится автоматически. Попытка передать неправильный тип вызовет ошибку на этапе компиляции.
Использование в компоненте:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function RegistrationForm() {
  const form = useTypedForm(
    userFormSchema,
    validators,
    { bio: 'Начальное значение' }
  );
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!form.validate()) return;
    
    const apiData = toApiFormat(
      form.values as FormValues<typeof userFormSchema>,
      userFormSchema
    );
    
    await fetch('/api/register', {
      method: 'POST',
      body: JSON.stringify(apiData),
    });
    
    form.reset();
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={form.values.email || ''}
        onChange={e => form.setValue('email', e.target.value)}
        onBlur={() => form.setFieldTouched('email')}
      />
      {form.errors.email && <span>{form.errors.email}</span>}
      
      <input
        type="number"
        value={form.values.age || ''}
        onChange={e => form.setValue('age', parseInt(e.target.value) || 0)}
        onBlur={() => form.setFieldTouched('age')}
      />
      {form.errors.age && <span>{form.errors.age}</span>}
      
      <button type="submit">Зарегистрироваться</button>
    </form>
  );
}
Автокомплит работает идеально - IDE подсказывает только существующие поля и их типы. Refactor rename пройдётся по всем использованиям. Удалил поле из схемы - компилятор покажет все места где код сломался.
Тестирование типов провожу через библиотеку для type assertions:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Проверка что схема генерирует правильные типы
type TestValues = FormValues<typeof userFormSchema>;
const assertEmailIsString: TestValues['email'] extends string ? true : false = true;
const assertBioIsOptional: undefined extends TestValues['bio'] ? true : false = true;
 
// Проверка что валидаторы типобезопасны
const testValidators: FormValidators<typeof userFormSchema> = {
  email: [required(), email()], // OK
  age: [required(), range(0, 150)], // OK
  // @ts-expect-error - minLength работает только со строками
  age: [minLength(5)],
};
 
// Проверка трансформации
type TestApi = ApiValues<typeof eventFormSchema>;
const assertDateTransformed: TestApi['date'] extends string ? true : false = true;
Runtime тесты используют готовые значения:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
describe('Form validation', () => {
  it('validates required fields', () => {
    const values: Partial<FormValues<typeof userFormSchema>> = {
      email: '',
      age: 25,
    };
    
    const errors = validateForm(values, validators);
    
    expect(errors.email).toBeDefined();
    expect(errors.age).toBeUndefined();
  });
 
  it('transforms data correctly', () => {
    const formData: FormValues<typeof eventFormSchema> = {
      title: 'Test Event',
      date: new Date('2024-01-01'),
      participants: 42,
    };
    
    const apiData = toApiFormat(formData, eventFormSchema);
    
    expect(typeof apiData.date).toBe('string');
    expect(typeof apiData.participants).toBe('string');
    expect(apiData.participants).toBe('42');
  });
});
Система масштабируется без боли. Добавил пятнадцать форм в проект за два дня - просто описал схемы и переиспользовал хук. На шестой форме обнаружил баг в валидаторе - исправил в одном месте, всё заработало везде. Раньше на такой рефакторинг уходила неделя с последующим месяцем фиксов багов в production.

Mapped types превратили разрозненные куски кода в систему. Схема это single source of truth, остальное вычисляется автоматически. Нет места для рассинхронизации типов, нет копипасты, нет забытых обновлений. Код может быть сложнее в написании но проще в поддержке - именно то что нужно для долгоживущих проектов.

Mapped types решают конкретную проблему - избавляют от ручной синхронизации типов при рефакторинге. Когда структура данных меняется в одном месте, производные типы обновляются автоматически. В проектах с сотнями интерфейсов это экономит недели работы и предотвращает баги от забытых обновлений. Но есть цена. Сложные маппинги убивают производительность компилятора, криптичные ошибки заставляют гуглить часами, а рекурсивные типы ломаются на циклических структурах. Код который выглядит элегантно в туториалах превращается в кошмар при отладке в реальном проекте с тысячами строк. Правило простое - использовать mapped types когда боль от ручного дублирования превышает боль от сложности типов. Три-четыре похожих интерфейса? Не стоит заморачиваться. Двадцать интерфейсов с одинаковым паттерном трансформации? Пишите маппинг, окупится за день.

Держите под рукой примеры из этой статьи. Когда столкнётесь с необходимостью маппинга в реальном проекте, не изобретайте велосипед - адаптируйте готовое решение под свою задачу. Время которое сэкономите на отладке потратите на что-то более полезное.

Создать редактор радиосхем для MVC5, используя TypeScript
Ребята!) Нужна инфа как возможно создать редактор,используя typescript (js нежелательно),на mvc 5...

Разбиение скомилированного Typescript на файлы
В проекте имеется множество typescript файлов, которые компилируются в один js. Но часть скриптов...

Изучение TypeScript - советы
Нуждаюсь в срочном освоении TypeScript. Поделитесь ресурсами, пожалуйста. Можно на русском и...

Не понятен пример кода из спецификации TypeScript
Читаю про объектные типы в спецификации на странцие 13, но не понятно из описания как устроен и...

Передать свойство класса в анонимную функцию TypeScript
как передать значение свойства класса в анонимную функцию. например следующий код работает...

TypeScript "PreComputeCompileTypeScript" how to fix in project
Выскакивает ошибка Везде исправление данной ошибки идёт путём редактирования файла ...

Решение кольцевых зависимостей TypeScript + RequreJS
Всем привет, возникла проблема с кольцевыми зависимостями при использовании наследования в...

TypeScript | extends error
Собственно, сделал такой класс: class trueDate extends Date { constructor(date: string)...

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

C# класс в TypeScript класс (перенос сущностей)
делается бекенд на C#, фронтенд на ts, общаются через REST API (http) сериализация обьектов...

Лучшие видео ресурсы о программировании, angualr 2, typeScript, react и все сомое вкусное
Всем доброй поры времени. Я нашел новый для себя, и как не странно, вообще новый ресур, с видео...

Как проводится отладка при использовании TypeScript
Присматриваюсь к Angular 2. Из прочитанных статей сложилось впечатление, что в мире Angular 2 есть...

Метки javascript, typescript
Размещено в Без категории
Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
И решил я переделать этот ноут в машину для распределенных вычислений
Programma_Boinc 09.11.2025
И решил я переделать этот ноут в машину для распределенных вычислений Всем привет. А вот мой компьютер, переделанный из ноутбука. Был у меня ноут асус 2011 года. Со временем корпус превратился. . .
Мысли в слух
kumehtar 07.11.2025
Заметил среди людей, что по-настоящему верная дружба бывает между теми, с кем нечего делить.
Новая зверюга
volvo 07.11.2025
Подарок на Хеллоуин, и теперь у нас кроме Tuxedo Cat есть еще и щенок далматинца: Хочу еще Симбу взять, очень нравится. . .
Инференс ML моделей в Java: TensorFlow, DL4J и DJL
Javaican 05.11.2025
Python захватил мир машинного обучения - это факт. Но когда дело доходит до продакшена, ситуация не так однозначна. Помню проект в крупном банке три года назад: команда data science натренировала. . .
Mapped types (отображённые типы) в TypeScript
Reangularity 03.11.2025
Mapped types работают как конвейер - берут существующую структуру и производят новую по заданным правилам. Меняют модификаторы свойств, трансформируют значения, фильтруют ключи. Один раз описал. . .
Адаптивная случайность в Unity: динамические вероятности для улучшения игрового дизайна
GameUnited 02.11.2025
Мой знакомый геймдизайнер потерял двадцать процентов активной аудитории за неделю. А виновником оказался обычный генератор псевдослучайных чисел. Казалось бы - добавил в карточную игру случайное. . .
Протоколы в Python
py-thonny 31.10.2025
Традиционная утиная типизация работает просто: попробовал вызвать метод, получилось - отлично, не получилось - упал с ошибкой в рантайме. Протоколы добавляют сюда проверку на этапе статического. . .
C++26: Read-copy-update (RCU)
bytestream 30.10.2025
Прошло почти двадцать лет с тех пор, как производители процессоров отказались от гонки мегагерц и перешли на многоядерность. И знаете что? Мы до сих пор спотыкаемся о те же грабли. Каждый раз, когда. . .
Изображения webp на старых x32 ОС Windows XP и Windows 7
Argus19 30.10.2025
Изображения webp на старых x32 ОС Windows XP и Windows 7 Чтобы решить задачу, использовал интернет: поисковики Google и Yandex, а также подсказки Deep Seek. Как оказалось, чтобы создать. . .
Passkey в ASP.NET Core identity
stackOverflow 29.10.2025
Пароли мертвы. Нет, серьезно - я повторяю это уже лет пять, но теперь впервые за это время чувствую, что это не просто красивые слова. В . NET 10 команда Microsoft внедрила поддержку Passkey прямо в. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2025, CyberForum.ru