Типы объектов и интерфейсы
Каталог содержит объекты одинакового назначения. Каждый курс имеет идентификатор, название, тему, число уроков и ссылку. Если одна запись случайно пропустит ссылку или поставит текст вместо количества, это проблема формы данных. Назовём эту форму Course, чтобы остальные функции могли ссылаться на один договор.
Интерфейс TypeScript описывает свойства значения для статической проверки. Он не создаёт новый объект, не добавляет поля и не выполняется в браузере. Если вы знакомы с интерфейсами C#, не переносите автоматически ожидания от CLR: в нашем примере совместимость определяется структурой обычных JavaScript-объектов. Задача урока — описать обязательные и необязательные данные курса и увидеть последствия этой формы в функции отображения.
Одна форма для разных записей
Добавьте полный src/model.ts:
export interface Course {
id: string;
title: string;
topic: string;
lessons: number;
url: string;
description?: string;
}
Первые пять свойств обязательны. Название Course теперь обозначает форму записи, а не конкретный курс. description? означает, что объект может не иметь описания. Это полезнее, чем придумывать пустую строку для каждой записи: отсутствие сведений и известное пустое содержание могут требовать разных решений интерфейса.
В src/data.ts добавьте импорт import type { Course } from './model.js' и измените объявление массива на export const courses: Course[] = .... Сами четыре записи сохраняются. Только первой добавьте description: 'Основы языка и браузерного каталога'. Полный файл находится в самостоятельном снимке lesson-03 в архиве серии; многоточие в этом абзаце обозначает место прежнего массива, а не готовую строку для копирования.
Аннотация массива проверяет каждую запись относительно выбранной формы. Ошибка lessons: '20' больше не описывает допустимый вариант данных: у Course.lessons уже заявлен числовой контракт. Новый случайный объект не может тихо расширить его только своим присутствием в массиве.
Импорт с type нужен только инструменту проверки. При выбранном verbatimModuleSyntax такой импорт явно стирается из результата. Тип интерфейса не является значением, которое можно загрузить и показать в консоли. Если попытаться использовать Course как конструктор, это будет другая задача, не соответствующая текущему определению.
Чтение необязательного описания
Полный src/app.ts выводит подписи четырёх курсов:
import { courses } from './data.js';
import type { Course } from './model.js';
function describe(course: Course): string {
return `${course.title}: ${course.description ?? 'Описание пока отсутствует'}`;
}
for (const course of courses) console.log(describe(course));
Для JavaScript ожидается его явное описание. Для остальных трёх записей ожидается текст «Описание пока отсутствует». Функция получает Course, поэтому типы обязательных полей известны, а при чтении description учитывается возможное отсутствие. Оператор ?? выбирает запасной текст именно для null или undefined; исходная модель не разрешает null, но выражение пригодно для чтения значения с отсутствующим свойством.
Если у курса явно записать пустую строку, ?? оставит эту строку. Это важно: наша текущая модель разрешает любой строковый текст описания, включая пустой. Если редакционный процесс запрещает пустое описание, понадобится проверка или нормализация отдельного правила. Интерфейс с string сам этого правила не выражает.
Настройка exactOptionalPropertyTypes делает заметной разницу между отсутствующим свойством и свойством, которому явно присвоен undefined. В текущем договоре description?: string допускает отсутствие, а присутствующее поле должно быть строкой. Чтобы разрешить именно явный undefined, пришлось бы включить его в тип поля. Такая необходимость возникает не всегда, поэтому не будем расширять договор без причины.
Обратите внимание, как это влияет на создание объектов. Если описания нет, просто не добавляйте ключ. В будущем парсере внешних данных мы будем условно добавлять поле в новый объект, вместо того чтобы бездумно копировать description: undefined. Так статический контракт поддержит принятую форму сериализации.
Структурная совместимость
Слово «интерфейс» может создать впечатление, что каждый объект обязан явно объявить свою принадлежность. Здесь это не так. Значение подходит функции, если содержит необходимые свойства совместимых типов. Рассмотрим самостоятельный фрагмент после импортов:
const extended = {
id: 'javascript', title: 'Современный JavaScript', topic: 'frontend',
lessons: 20, url: './courses/javascript.html', editor: 'Редакция'
};
console.log(describe(extended));
Дополнительное поле editor не мешает передать переменную extended в describe: функция использует предусмотренную часть объекта. Но если записать совершенно новый объектный литерал прямо в место, ожидающее Course, проверка лишних свойств помогает обнаружить опечатки. Поэтому поведение проверки свежего литерала и переменной с более богатой структурой не надо принимать за противоречие.
Например, ошибочное поле titel вместо title не выполнит обязательное требование независимо от наличия других свойств. А поле editor может быть уместным в расширенной модели, но не должно случайно появляться в публичном контракте только ради одного вызова. Определение границы модуля остаётся решением автора, а не соревнованием по обходу диагностики.
Обычная переменная может хранить более подробный объект, чем тип параметра функции. Тип параметра определяет, что функция вправе использовать. Если describe начнёт обращаться к course.editor, собственная сигнатура уже не даёт ей такого права. Нужно либо ввести новый контракт, либо передать редакционные сведения отдельно.
Где заканчивается описание формы
Сейчас topic является строкой. Значение frontend, publishing или случайное frontned одинаково соответствует string. Аналогично число -10 и число 20 оба соответствуют number. Интерфейс полезен, но он не выражает все смысловые ограничения нашего каталога. Это хороший повод разделить правила: конечные значения темы можно описать типом, а положительное целое количество проверять при работе с внешними данными.
Изменение модели затрагивает реальные обязанности
Представьте, что редакция добавляет обязательную дату обновления курса. Если записать новое свойство в Course, существующие локальные объекты должны его предоставить. Это полезно только тогда, когда данные действительно будут доступны у каждой записи. Не стоит делать поле обязательным ради красивой подсказки и затем повсюду заполнять его фиктивной строкой.
Если дата доступна лишь у части архива, необязательное поле будет честнее. Но каждая функция, желающая её показать, должна определить отсутствие. Можно скрыть подпись даты, вывести понятное сообщение или предложить обновление материала. Тип обозначает возможность, а редакционная и интерфейсная политика выбирают реакцию.
Точно так же изменение lessons со строки на число должно отражать действительную нормализацию данных. Нельзя объявить число в интерфейсе и продолжить принимать строковый JSON без проверки. Локальный исходник может быть согласован, а внешний источник — остаться историческим. Это причина отдельного этапа входного парсера в шестом уроке.
Структурный контракт удобно держать небольшим и предметным. У курса не должны внезапно появляться DOM-узел карточки, статус сетевой загрузки и функция публикации только потому, что всё связано с каталогом. Такие данные относятся к представлению или состоянию приложения. Сохранение отдельных обязанностей позволит переиспользовать Course в сводке, форме и API-границе без зависимости от конкретного браузерного элемента. В этом уроке интерфейс описывает только сведения о курсе, и функция describe получает ровно эту модель.
URL также пока просто строка. Тип не знает, существует ли страница назначения и безопасен ли произвольный протокол. В fixture ссылки ограничены локальными страницами courses/, но эту политику понадобится проверять в исполняемом коде. Поставив Course[] после результата JSON.parse, вы не заставите JSON пройти проверку полей.
Объектные контракты и необязательные свойства описаны в Object Types. В нашей программе наблюдаемый результат — четыре согласованные подписи без обращения к отсутствующему описанию как к гарантированной строке. Исходники не запускались; ожидаемое поведение выведено из данных и выражения ??. В следующем уроке сузим допустимые темы и научимся описывать несколько вариантов значения одним контрактом.