Типы и границы модулей
Каталог уже умеет загрузить JSON, проверить его, прочитать отбор и построить список. Сейчас app.ts знает несколько внутренних путей. При небольшом проекте это нормально, но по мере расширения каждое представление начнёт повторять те же импорты. Перенос файлов тогда затронет представления, которым не требуется знать внутреннее устройство каталога.
Выделим небольшой публичный вход модуля каталога. Его договор составят операции, нужные приложению, и типы их значений. Алгоритмы и четыре курса сохраняются: начальная сумма 60, frontend — 48. Самостоятельный снимок lesson-21 находится в архиве продолжения. Выполнение программы и компилятора при подготовке не проводилось.
Какую границу мы строим
Модуль не обязан совпадать с единственным файлом. Для потребителя каталог может выглядеть как набор из четырёх операций: загрузить данные, прочитать условие формы, применить его и отобразить результат. Внутри остаются несколько файлов, каждый со своей задачей. Новый вход связывает их в маленький договор использования.
Мы не экспортируем readCatalog отдельно, потому что приложение теперь загружает данные через loadCourses. Внутренний загрузчик продолжает вызывать читателя JSON. Если позже появится другое устройство чтения, потребитель не должен переписывать начальную загрузку только из-за перестановки вспомогательных файлов.
Это организационное решение. TypeScript не запрещает другому исходнику обратиться прямо к reader.ts, если путь известен. Для строгого ограничения импорта понадобятся дополнительные правила проекта или границы опубликованного пакета. Нельзя назвать новый index механизмом безопасности или скрытием файлов от браузера.
Вход каталога
Создайте src/catalog/index.ts. Это полный файл:
export { loadCourses } from '../api.js';
export { readFilter, applyFilter } from '../form.js';
export { renderCourses } from '../view.js';
export type { Course, Topic, TopicFilter } from '../model.js';
export type { Filter } from '../form.js';
export type { Result, CatalogError } from '../result.js';
Первые три строки экспортируют функции, которые существуют во время выполнения. Остальные строки экспортируют сведения о типах. Course описывает объект, Filter — условие отбора, Result и CatalogError — возможный результат загрузки. Для использования этих описаний не нужно создавать такие объекты в отдельном глобальном пространстве.
Запись export type явно отделяет описания от значений. Она не выпускает функцию с именем Course и не превращает интерфейс в JavaScript-класс. Такое разделение особенно удобно при включённом verbatimModuleSyntax: по исходнику видно, какие импорты и экспорты должны сохраниться в JavaScript, а какие относятся только к проверке типов. Поведение этой настройки описано в справочнике TypeScript.
Мы перечисляем имена, а не используем несколько export *. Благодаря этому добавленная внутренняя функция не становится частью публичного входа автоматически. Размер списка легко оценить при чтении: четыре операции и шесть описаний. Разработчик потребителя сразу понимает, какие возможности предусмотрены.
При необходимости можно экспортировать тип, который использует другой публичный тип. Однако это не означает, что нужно выставить наружу каждое внутреннее имя. Публичный договор должен быть достаточен для реальных задач потребителя; расширять его на всякий случай обычно менее удобно, чем добавить понятную операцию после появления задачи.
Приложение использует новый договор
Полный src/app.ts теперь импортирует операции из одного входа:
import { loadCourses, renderCourses, readFilter, applyFilter } from './catalog/index.js';
import type { Course } from './catalog/index.js';
const list = document.querySelector('#catalog-list');
const status = document.querySelector('#catalog-status');
const form = document.querySelector('#catalog-controls');
const fieldset = form?.querySelector('fieldset');
const reload = document.querySelector('#catalog-reload');
if (!(list instanceof HTMLUListElement) || !(status instanceof HTMLElement) ||
!(form instanceof HTMLFormElement) || !(fieldset instanceof HTMLFieldSetElement) || !(reload instanceof HTMLButtonElement)) {
throw new Error('Не найдены элементы каталога');
}
const ui = { list, status, form, fieldset, reload };
let accepted: readonly Course[] = [];
let ready = false;
let busy = false;
function showSelection(): void {
renderCourses(ui.list, ui.status, applyFilter(accepted, readFilter(ui.form)));
}
async function reloadCatalog(): Promise<void> {
if (busy) return;
busy = true;
ui.reload.disabled = true;
ui.fieldset.disabled = true;
ui.status.textContent = 'Загрузка';
try {
const result = await loadCourses('./data/courses.json');
if (!result.ok) { ui.status.textContent = result.error.message; return; }
accepted = result.value;
ready = true;
ui.fieldset.disabled = false;
showSelection();
} catch (error: unknown) {
ui.status.textContent = error instanceof Error ? error.message : 'Непредвиденная ошибка';
} finally {
busy = false;
ui.reload.disabled = false;
ui.fieldset.disabled = !ready;
}
}
ui.form.addEventListener('submit', event => {
event.preventDefault();
if (!ready || busy) return;
try { showSelection(); }
catch (error: unknown) { ui.status.textContent = error instanceof Error ? error.message : 'Ошибка формы'; }
});
ui.reload.addEventListener('click', () => { void reloadCatalog(); });
void reloadCatalog();
Импорт Course помечен type, поскольку значение этого имени нигде не вызывается и не создаётся. Остальные четыре имени используются в исполняемом коде. Программа по-прежнему проверяет найденные DOM-узлы, хранит последние принятые данные и допускает одну попытку загрузки за раз.
Верхняя часть файла стала короче, но смысл обработчиков сохранился. После успешной загрузки полевая группа включается до readFilter: отключённые поля не вошли бы в FormData. Затем применяется текущее условие формы. Сводка относится к отобранным данным, а не обязательно ко всему исходному JSON.
При all и минимуме ноль ожидаются четыре курса и 60 уроков. При frontend и минимуме 16 ожидаются две записи и 36. Отказ повторной загрузки сохраняет прежнюю принятую модель. Изменение пути импортов само не является причиной изменять эти результаты.
Значение import находится во времени выполнения
В TypeScript понятие модуля связано с импортами и экспортами верхнего уровня. Это описано в разделе Modules. В нашем каталоге каждому исходному модулю соответствует файл JavaScript, который будет загружаться браузером после собственной компиляции читателем.
В исходниках написано расширение .js. Например, ./catalog/index.js из src/app.ts соответствует исходнику src/catalog/index.ts, а в результате должен соответствовать dist/catalog/index.js. TypeScript проверяет связь с исходным файлом, но браузер впоследствии запрашивает адрес настоящего JavaScript. Если выпустить только app.js без остальных модулей, корректные типы не помогут загрузить отсутствующий index.
Относительные пути вычисляются от импортирующего модуля. Поэтому ../api.js внутри catalog/index относится к соседнему каталогу src, а после выпуска — к соседнему уровню dist. Путь JSON в fetch, напротив, разрешается относительно документа. Совпадение строкового синтаксиса не означает совпадения базового адреса.
import type не создаёт побочного эффекта загрузки. Если модулю требуется выполнить регистрацию или инициализацию, её нельзя передать одним импортом типа. В текущем примере такой скрытой регистрации нет: все полезные действия происходят через явно вызываемые операции. Это помогает читать зависимости приложения по обычному коду.
Внутренние файлы не должны возвращаться через вход
Потребитель app импортирует catalog/index. Сам index импортирует api, form и view. Эти внутренние файлы продолжают обращаться к model и reader непосредственно. Не нужно переписывать каждый их импорт обратно на catalog/index только ради единообразия.
Иначе получится путь вида index → api → index. Такие циклы бывают допустимы в системе модулей, но усложняют понимание порядка инициализации значений. Мы сохраняем простое направление: внешнее приложение обращается к входу, вход перечисляет реализации, реализации используют свои вспомогательные модули.
Например, api.ts знает readCatalog, потому что именно он передаёт строку на проверку. form.ts знает TopicFilter, потому что создаёт условие отбора. Приложению не нужно повторять эти знания. Такая граница выражает реальные обязанности файлов, а не только короткие имена импортов.
Отдельно учитывайте изменение публичного контракта. Если переименовать loadCourses в fetchCatalog, потребителям потребуется обновление, даже если реализация не менялась. Если переставить внутренний reader, сохранив экспорт loadCourses и его подпись, приложение может остаться прежним. В этом и заключается практическая польза выбранного входа.
Публичный вход также помогает обсуждать изменения по назначению. Если новое представление должно только показать список, ему достаточно renderCourses и Course; оно не обязано импортировать функции формы. Один файл входа не заставляет каждого потребителя пользоваться всеми экспортами одновременно.
Но такой index не обещает уменьшить сетевую загрузку до одной нужной функции. В браузерном графе модулей сохраняются обычные зависимости экспортов. Оптимизация исключения неиспользуемого кода относилась бы к отдельному сборщику и его настройкам. Здесь цель границы — устойчивый договор исходников, а не выдуманное измерение размера результата.
Типы сохраняют смысл данных
Публичный Course не утверждает, что любая похожая JSON-запись уже проверена. Значение получает этот договор после parseCourses, а результат loadCourses выражает возможность отказа. Перенос экспорта интерфейса не переносит в браузер проверяющий код автоматически; он уже существует в reader/model и вызывается настоящей операцией.
Так же readonly не делает каталог физически неизменяемым. Оно ограничивает обычного TypeScript-потребителя публичной модели. Другой JavaScript-код или намеренное утверждение типа способны нарушить это ожидание. Нужен отдельный runtime-договор, если проект требует настоящего замораживания или изоляции состояния.
При чтении снимка сначала проследите app → catalog/index → api → reader → model. Затем проследите отдельную ветку app → form → view. Первая принимает данные, вторая использует уже принятую модель для представления. Новый вход делает обе задачи доступными, сохраняя внутреннюю структуру понятной.
В следующем уроке разберём конфигурацию проекта: какие настройки помогают поддерживать этот договор и почему проверка типов не равна выпуску файлов или проверке браузерного поведения.