Постепенный переход с JavaScript
У старого сайта редко получается заменить весь JavaScript одной правкой. Пока часть модулей уже типизирована, другие продолжают работать как прежде. Полезно научиться уточнять их договор постепенно: сначала определить границу, затем описать её в JavaScript, и только после этого менять расширение файла.
В заключительном уроке рассмотрим старую функцию сводки каталога. Она получает курсы и возвращает количество записей вместе с суммой уроков. Алгоритм сохранится, а договор станет явным. Полный самостоятельный lesson-24 находится в архиве продолжения. Программы и компилятор не запускались; приведённые результаты ожидаются из включённых данных.
Выберем маленькую границу миграции
У нас уже есть тип Course и runtime-парсер внешнего JSON. Поэтому функция сводки не должна повторно разбирать сетевой ответ. Она получает внутренний проверенный массив. Этот небольшой договор удобен для первого шага: нет DOM, задержек, хранения и нескольких скрытых источников данных.
В папке migration включён исходный образец summary-before.js:
export function summarize(courses) {
return [courses.length, courses.reduce((total, course) => total + course.lessons, 0)];
}
Первое значение результата — число курсов, второе — сумма lessons. Начальный ноль у reduce важен для пустого массива: сводка пустого набора должна быть 0/0. Порядок двух позиций тоже имеет смысл. Если поменять их местами, оба значения останутся числами, но потребитель получит неправильный договор.
Мы не включаем этот образец в активный src. Он сохраняет исходный вид для сравнения с последующими шагами. В текущем самостоятельном снимке каталог продолжает использовать обычное представление из шага 21, а JavaScript-библиотека оформления из шага 23 остаётся отдельным упражнением. Миграция сводки не зависит от неё.
Сначала допустим JavaScript в проект
Полный tsconfig.json смешанного проекта:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": [
"ES2022",
"DOM"
],
"types": [],
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noEmitOnError": true,
"verbatimModuleSyntax": true,
"allowJs": true,
"checkJs": true
},
"include": [
"src/**/*.ts",
"src/**/*.js"
]
}
allowJs разрешает JavaScript-исходникам участвовать в программе TypeScript. checkJs включает сообщения о проблемах внутри этих файлов. Это разные назначения: одно разрешает присутствие языка, второе уточняет отношение к его проверке. Они описаны в официальных справочниках allowJs и checkJs.
Include теперь перечисляет и ts, и js внутри src. Это явная область активной миграции. Папка migration туда не входит, и её образцы не импортируются активными исходниками. Поэтому summary-before.js и summary-after.ts не конкурируют за один выходной модуль.
RootDir остаётся src, а outDir — dist. Выходные файлы отделены от исходных: инструменту не предлагается переписать оригинальный JavaScript на месте. Остальные строгие настройки сохраняются, потому что смешанный проект должен пользоваться тем же договором Course, null и необязательных полей.
Добавим JSDoc к действующей функции
Полный активный src/summary.js:
/**
* @param {readonly import('./model.js').Course[]} courses
* @returns {readonly [number, number]}
*/
export function summarize(courses) {
return [courses.length, courses.reduce((total, course) => total + course.lessons, 0)];
}
Комментарий сообщает тип параметра и результата, сохраняя JavaScript-синтаксис тела функции. import('./model.js').Course здесь является ссылкой на тип внутри JSDoc; она не добавляет обычный runtime-импорт модели в JavaScript. Сам алгоритм остаётся тем же.
Параметр объявлен как readonly-массив Course. Функция читает записи и не должна добавлять курс или менять его поля через обычный типизированный доступ. Результат описан readonly-кортежем двух чисел, благодаря чему число позиций известно заранее.
Кортеж полезнее произвольного number[]: потребитель получает сведения, что есть именно две позиции. Однако эти позиции всё ещё имеют одинаковый примитивный тип. Если автор поменяет количество и сумму местами, система типов не обязательно отличит такую смысловую ошибку. Для более самодокументируемого публичного договора можно позже выбрать объект с полями count и lessons, но это было бы отдельным изменением API.
Добавление JSDoc не создаёт runtime-валидацию массива. Неверная внешняя запись всё ещё должна быть обнаружена при чтении JSON, а не выдана функции с помощью утверждения типа. Миграция уточняет уже выбранную границу, не передвигает её незаметно к любым данным.
Подключим сводку к каталогу
Полный src/app.ts:
import { loadCourses, renderCourses, readFilter, applyFilter } from './catalog/index.js';
import type { Course } from './catalog/index.js';
import { summarize } from './summary.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;
const [count, lessons] = summarize(accepted);
console.log(`Проверенная модель: ${count} курса, ${lessons} уроков`);
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();
Вызов summarize происходит после успешного Result от loadCourses. Поэтому console-сводка относится ко всему проверенному ответу. Затем showSelection применяет условие формы и строит пользовательскую сводку отобранных карточек. Это две разные операции над одним массивом, и их нужно различать при чтении ожидаемого результата.
Для включённого JSON ожидается сообщение «Проверенная модель: 4 курса, 60 уроков». Если человек выбрал frontend/16, DOM затем показывает 2/36. Разные числа здесь не являются противоречием: первая строка характеризует исходный принятый набор, вторая — выбранную часть.
При неуспешной загрузке summarize вообще не вызывается для нового ответа. Прежние accepted и карточки сохраняются по прежней политике. Ошибка схемы не должна порождать сводку на основе произвольного внешнего значения. Наличие JavaScript-функции в смешанном проекте это правило не меняет.
Затем подготовим TypeScript-вариант
В migration/summary-after.ts включён полный вариант для будущей замены:
import type { Course } from './model.js';
export function summarize(courses: readonly Course[]): readonly [number, number] {
return [courses.length, courses.reduce((total, course) => total + course.lessons, 0)];
}
Здесь типы перенесены из комментария в сигнатуру, а Course импортируется через import type. Функция остаётся той же: сначала длина массива, затем reduce с начальным нулём. Это следующий этап миграции, подготовленный для чтения; активный снимок пока использует JSDoc-вариант.
Чтобы перейти к нему позднее, нужно убрать src/summary.js и поместить TypeScript-содержимое в src/summary.ts. Не оставляйте две реализации с одинаковым базовым именем: обе претендовали бы на dist/summary.js. Вариант в migration не является отдельным компилируемым модулем этой папки: его импорт './model.js' рассчитан именно на размещение рядом с src/model.ts после замены.
Импорт приложения может остаться './summary.js', потому что это адрес будущего выходного JavaScript. Переименование исходника не требует добавлять расширение ts в браузерный путь. После собственной проверки и нового выпуска читателю нужно убедиться, что на сервер попал новый результат, а не старый dist.
Подробный подход к смешанному проекту изложен в Migrating from JavaScript. В нашем примере мы намеренно выбираем маленький модуль и не одновременно меняем алгоритм, способ загрузки и структуру страниц.
Синтаксис JSDoc важен сам по себе. TypeScript понимает определённые формы @param и @returns, как описано в JSDoc Supported Types. Обычная человеческая фраза «список курсов» не заменяет выражение типа. Поэтому комментарий содержит readonly-массив и кортеж, а смысл порядка позиций объясняется в тексте урока.
После переноса в TypeScript не нужно поддерживать два отдельных места объявления одной подписи. Исполняемый файл summary.ts становится владельцем функции, а приложение импортирует его будущий JavaScript-результат. Если в проекте остаются другие JavaScript-модули, allowJs/checkJs продолжают быть полезны; выключать их по факту переименования единственного файла было бы слишком ранним завершением общей миграции.
Выбор первого модуля также влияет на цену ошибок. Для небольшой сводки легко сравнить источник и результат по двум понятным значениям. Начинать с большого обработчика, который одновременно меняет DOM и делает запросы, сложнее: изменение типа может скрыть перемену порядка эффектов. Сначала устойчивый договор, затем новая форма записи — хороший порядок для текущего каталога.
Чего миграция пока не доказывает
Наличие checkJs не говорит, что программа уже проверена запуском инструмента. При подготовке команды не выполнялись. По чтению можно установить соответствие порядка позиций и намерения reduce, но настоящее действие выбранной версии компилятора и браузера относится к будущему этапу проверки.
Так же нельзя заключить, что типизация восстановила старый трафик сайта или сохранила все production-URL. Это отдельный договор маршрутов и содержимого. Наш каталог использует локальные учебные courses/*.html; они помогают разбирать модель, а не заменяют миграционный реестр ProfessorWeb.
Для следующего собственного модуля сначала запишите, какие данные в него поступают и какой смысл имеет результат. Найдите место, где неизвестное значение становится проверенным. Затем уточните договор JSDoc и замените только выбранную реализацию, когда её граница понятна. Такой порядок уменьшает число одновременно меняющихся условий.
Серия завершает путь от первых аннотаций к полноценной границе данных, формы, DOM, модулей и старого JavaScript. Продолжать каталог можно новой функцией с явным входом, не повторяя всю конфигурацию и парсер. Сохраните прежние 60 уроков как исходный пример и добавляйте новые возможности так, чтобы их результат можно было объяснить по отдельной задаче.