Один взгляд на файл с типами и желание привести его в порядок нередко превращаются в борьбу с копипастой и неясными интерфейсами. В TypeScript есть набор приёмов, которые позволяют не повторять структуру вручную и поддерживать код чище — среди них встроенные утилиты и mapped types. Я расскажу о том, что это такое, зачем это нужно и как применять на практике, не превращая типы в магические сущности, которые сложно отлаживать.

Что такое utility types и mapped types в общих чертах

Utility types — это готовые типовые преобразования, которые встроены в TypeScript или легко реализуются с помощью языковых конструкций. Они решают типичные задачи: сделать все поля необязательными, выбрать подмножество свойств, получить тип возвращаемого значения функции и так далее. Mapped types — это более общий инструмент: с их помощью вы можете перебирать ключи типа и создавать новый тип по правилам, заданным для каждого ключа.

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

Короткая анатомия mapped type

Синтаксис mapped type выглядит примерно так: { [P in K]: T } — где K обычно набор ключей, а T выражает тип для каждого P. Добавляя модификаторы readonly или ? можно управлять мутабельностью и обязательностью свойств. Mapped types отлично сочетаются с conditional types и keyof, это даёт невероятную гибкость.

Например, превращение всех свойств в readonly делается буквально в одну строку; для вложенных структур потребуется рекурсия, но сам принцип остаётся тот же: вы задаёте правило и TypeScript применяет его ко всем ключам.

Полезные встроенные utility types

TypeScript поставляется с набором утилит, которые покрывают большинство ежедневных задач. Вот несколько наиболее часто используемых: Partial, Required, Readonly, Pick, Omit, Record, ReturnType, Parameters, Exclude, Extract, NonNullable, InstanceType. Каждая из них решает отдельную задачу, и их можно комбинировать.

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

Тип Назначение
Partial Делает все свойства T необязательными
Required Делает все свойства T обязательными
Readonly Делает все свойства T только для чтения
Pick Выбирает подмножество свойств T по ключам K
Omit Исключает свойства K из T
Record Создает объект с ключами K и значениями T

Когда использовать встроенные утилиты

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

Но если структура сложная, особенно с вложенными объектами, встроенных утилит может не хватить. Тогда на сцену выходят mapped types и собственные типовые шаблоны.

Примеры mapped types и их практические применения

Ниже несколько примеров, которые встречались мне в реальных проектах. Они демонстрируют, как с помощью mapped types решать конкретные задачи без дублирования типов.

1) Readonly для всего дерева (DeepReadonly). Встроенный Readonly делает только первый уровень свойств readonly, но для вложенных объектов это не сработает. Мapped type позволяет рекурсивно проходиться по всем свойствам:

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

2) Превращение значений в промисы. Если API должен возвращать Promise для каждого поля, можно написать общий шаблон:

type PromisifyMembers = {
  [P in keyof T]: Promise
};

3) Сопоставление ключей с другими именами. С появлением key remapping (as) можно изменять имена свойств в процессе маппинга:

type RenameKeys = {
  [K in keyof T as `_${string & K}`]: T[K]
};

Сложные рецепты: DeepPartial и защитные приёмы

DeepPartial — частая потребность при обновлении сущностей на сервере. Стандартный Partial не справляется с вложенными объектами, поэтому приходится писать рекурсивный mapped type. Но нужно быть осторожным с типами, которые считаются object в TS, например, функциями — их не стоит рекурсивно обрабатывать.

type DeepPartial = {
  [P in keyof T]?: T[P] extends object ? DeepPartial : T[P];
};

В реальном проекте я добавлял дополнительную проверку с исключением функций и массивов, чтобы не испортить поведение коллекций и колбэков.

Ошибки и подводные камни

Mapped types мощные, но есть нюансы. Distributive conditional types ведут себя не всегда интуитивно — если вы используете условный тип над union-типом, он может распределиться по каждому члену union. Это иногда полезно, но может привести к неожиданным результатам при композиции утилит.

Ещё одна проблема — избыточная сложность типов. Когда тип становится слишком хитро сконструированным, IDE перестаёт подсказывать удобно, а время компиляции увеличивается. Я однажды сделал слишком универсальный DeepMerge, и команда потратила день на то, чтобы понять ошибки на стыке модулей.

Советы для избегания проблем

1) Писать маленькие понятные типы и по возможности комбинировать их, а не создавать одну большую универсальную конструкцию. 2) Добавлять тесты типов с помощью utilities вроде ts-expect-error или type-tests. 3) Включать строгие настройки компилятора, это часто помогает заметить ошибку на ранней стадии.

Я предпочитаю проверять типы в простых примерах и только затем применять на всю кодовую базу. Это экономит время и облегчает ревью.

Практические рецепты: шаблоны, которые я использую

Ниже несколько приёмов, которые реально ускоряют работу и часто встречались в моих проектах. Они не идеальны для всех случаев, но дают рабочую основу.

  • Use Record for enums: типизируйте словари через Record чтобы исключить опечатки в ключах.
  • Combine Pick и Omit when reshaping props: вместо ручного описания создавайте представления через Pick и Omit.
  • Create small helpers: например, NullableToOptional — переводит Nullable свойства в необязательные.

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

Пример: форма и API

В проекте с формами приходилось конвертировать данные из формы в payload для API. Поля формы могли отсутствовать, поэтому использовал DeepPartial для типов запроса на обновление. Это избавляло от множества условных проверок в коде сборки запроса и упростило валидацию на стороне клиента.

type User = {
  id: string;
  profile: {
    name: string;
    age?: number;
  };
};

type UpdateUserPayload = DeepPartial;

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

Инструменты и дальнейшее чтение

Для углубления полезно посмотреть официальную документацию TypeScript по Utility Types и Mapped Types. Также есть хорошие статьи и обсуждения на GitHub, где описаны тонкости conditional types и key remapping. Наконец, проекты с набором type-tests помогают понять практические ограничения компилятора.

Я иногда сохраняю набор собственных типовых шаблонов в маленькой библиотеке внутри репозитория, чтобы не писать их заново и иметь единое место для правок. Это упрощает распространение проверенных решений в команде.

Практическая проверка: как не сломать API типов

Несколько простых правил, которые помогут держать типы в здравом виде: документируйте сложные типы, давайте им говорящие имена и оставляйте маленькие комментарии с примерами использования. При изменениях типов запускайте сборку и тесты, а если тип изменился глобально — уведомляйте команду.

Работа с типами — это не самоцель, а инструмент повышения качества. Когда типы понятны и предсказуемы, разработка ускоряется и багов становится меньше.

Что взять на практике

Mapped types дают контроль над структурой данных: можно делать свойства необязательными, только для чтения, превращать значения и переименовывать ключи. Utility types решают частые задачи быстро и читабельно. Вместе они позволяют выразить большинство требований без дублирования типов и без ручных трансформаций.

Главная задача — не погружаться в излишнюю универсализацию. Простые, понятные шаблоны облегчают поддержку и делают код более прозрачным для коллег.

Если вы хотите, могу подготовить набор коротких примеров под ваш проект — такие примеры ускоряют внедрение и уменьшают количество ошибок в типизации.