Преобразование типов объектов
Пусть таблица каталога должна знать, какие поля курса показывать. Можно вручную написать тип с флагами title, topic, lessons и остальными именами. Но при добавлении поля в Course такой второй список легко забыть обновить. Преобразуем исходный тип в форму флагов видимости.
Mapped type строит новый объектный тип по набору ключей. Значения настоящего курса при этом не преобразуются. Мы создаём договор для отдельного объекта настроек, который затем напишем в исходнике. Результат урока — полный согласованный набор логических флагов, включая флаг необязательного описания.
От поля курса к флагу
Для каждого ключа K из keyof T зададим логическое значение. В отличие от исходного курса, у настроек все флаги обязательны. Даже если описание имеется не у каждой записи, интерфейс должен явно знать, хочет ли он его показывать. Поэтому необязательность нужно снять осознанно.
В самостоятельном снимке lesson-12 весь src/app.ts выглядит так:
import type { Course } from './model.js';
type FieldFlags<T> = { readonly [K in keyof T]-?: boolean };
const visible: FieldFlags<Course> = {
id: false, title: true, topic: true, lessons: true,
url: false, description: true
};
console.log(`Название видно: ${visible.title}`);
console.log(`Описание видно: ${visible.description}`);
Ожидаются строки «Название видно: true» и «Описание видно: true». Пример не показывает таблицу в браузере: он демонстрирует форму настроек. Функции модели и исходные четыре курса сохранены в снимке, хотя этот конкретный файл использует только импорт типа.
Часть [K in keyof T] относится к описанию типа, а не к циклу JavaScript. Для каждого ключа появляется свойство результата. boolean справа заменяет тип его значения: например, числовое lessons становится логическим флагом, строковое title тоже становится логическим флагом.
Модификатор -? снимает необязательность. Без него необязательное description могло бы остаться необязательным и в преобразованной форме. Тогда отсутствие настройки означало бы ещё один неописанный режим. Мы вместо этого требуем явное true или false для каждого поля.
Модификатор readonly запрещает запись через значение этого договора. Такой объект можно считать конфигурацией текущего представления. Если пользователь должен менять настройки, понадобится либо изменяемый тип состояния, либо создание новой конфигурации. Сам статический модификатор не препятствует физической мутации через иной alias.
Что заметит добавление поля
Представьте, что позже у Course появится обязательное поле duration. Набор keyof Course расширится, а FieldFlags<Course> начнёт требовать ещё один флаг. Теперь место настройки станет видно автору при обычной проверке исходников. Это полезнее скрытого значения по умолчанию, о котором таблица ничего не знает.
Возможна и другая политика: все новые поля скрываются автоматически. Тогда можно выбрать частичную настройку и явно реализовать значение false для отсутствующего ключа. Но это уже иной контракт. Главное — не смешивать оба подхода случайно: полный mapped type здесь выбран именно ради обязательного решения для каждого поля.
Исходная модель не изменяется. Флаг visible.description существует даже для курса, у которого отсутствует описание. При отображении нужны две отдельные проверки: разрешает ли настройка показывать поле и есть ли у конкретной записи значение. Тип флагов не создаёт отсутствующий текст.
Преобразование без замены значений
Для сравнения рассмотрим ещё один тип, который сохраняет значения исходной формы, но позволяет менять поля. Это самостоятельный фрагмент для чтения после определения FieldFlags:
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type EditableCourse = Mutable<Course>;
Здесь справа используется T[K], поэтому строки остаются строками, количество — числом, тема — прежним набором литералов. Снимается только readonly. Необязательность описания в этой форме сохраняется, поскольку мы не указали -?.
Однако объявление EditableCourse не копирует курс. Если присвоить тому же объекту другую статическую форму, ссылка может продолжать указывать на общие данные. Для настоящего редакторского черновика обычно создают отдельное значение, определяют допустимые поля и способ подтверждения изменений. Преобразование типа не выполняет эту работу за приложение.
Это удобно сравнить с операцией map у массива. Обычный map вызывается при выполнении и создаёт новые значения по функции. Mapped type анализируется инструментом и создаёт описание формы. Совпадение слова «преобразование» не должно скрывать, что эти действия находятся на разных этапах.
Поля данных и настройки интерфейса
Полный набор флагов помогает сохранить согласованность с моделью, но не обязан определять весь интерфейс. У таблицы могут быть вычисляемые колонки, которых нет в Course: например, подпись «20 уроков». И наоборот, URL может использоваться как адрес ссылки, а не как видимая колонка. Для таких случаев нужен собственный набор колонок или отдельная модель представления.
Наш пример сознательно ограничен флагами исходных полей. Так мы видим механизм преобразования, не добавляя заранее конструктор таблицы. На следующем шаге стандартные utility types дадут более предметные формы создания и обновления, где набор полей выбирается по назначению операции.
При описании словарей обращайте внимание на конечность ключей. Record<string, boolean> разрешает произвольные строковые имена; он не заставит автора перечислить все шесть полей курса. FieldFlags<Course> связан именно с текущими ключами Course. Это различие определяет, будет ли новая колонка обнаружена при изменении модели.
Полная настройка помогает при изменении редакционного интерфейса
Допустим, описание по умолчанию скрыто. Тогда достаточно поменять description: true на false в реальном объекте visible; тип остаётся прежним, потому что оба значения являются допустимыми логическими флагами. Это изменение политики показа, а не изменение схемы курса. Сам вывод второго сообщения станет «Описание видно: false» после самостоятельного получения нового JavaScript.
Если вместо логического значения написать строку 'false', модель флагов не совпадёт с данными. В JavaScript такая строка к тому же является истинной в условии, что создало бы другую ошибку представления. Наш договор требует именно boolean и помогает не смешивать машинные настройки с текстовыми подписями.
При добавлении optional-поля в Course модификатор -? всё равно потребует явного флага. Это сознательный выбор: наличие данных у каждой записи может быть необязательным, а решение о показе этой категории данных должно быть известно. Снятие optional не требует сделать описание обязательным в исходных курсах.
Для сохранённых пользовательских предпочтений возможна частичная форма. Тогда старые настройки, созданные до новой версии модели, могут не содержать новых ключей. Нужны runtime-значения по умолчанию и миграционная политика. Внутренний полный объект visible можно получить после такой нормализации, но один mapped type не выполнит миграцию хранилища. Поэтому полезно различать вход настроек и окончательную конфигурацию представления, точно так же как мы различаем внешний JSON и внутренний Course.
Не стоит утверждать, что mapped type гарантирует корректность JSON-настроек от посетителя. Если настройки приходят из хранилища или API, они тоже требуют проверки значений. Форма исходного литерала и внешний объект имеют разные границы доверия. То же правило шестого урока действует здесь без исключений.
Механизм ключей и модификаторов описан в Mapped Types. Результат нашего урока — логический контракт, который остаётся связанным с исходной моделью, и конкретная конфигурация шести флагов. При подготовке исходники только прочитаны, не скомпилированы. В следующем уроке используем готовые преобразования, чтобы отделить создание курса от разрешённых изменений существующего курса.