Симптом миграции легко узнать по pull request: несколько десятков файлов получают расширение .ts, вокруг каждого неудобного вызова появляется any, а выпуск задерживается из-за неясного набора ошибок. Проблема не в самом TypeScript. Команда начала заменять суффиксы файлов, не договорившись, какие данные становятся проверяемыми и где разрешено временно сохранить старый JavaScript. Цена — длинная ветка, в которой сборка то проходит, то нет, а новые типы почти не меняют риск ошибки.
В октябре 2019 года я бы не ставил задачу «перевести приложение на TypeScript». Она не сообщает, где остановиться и как выпускать изменения. Более полезная формулировка: взять один входной контракт, сохранить рабочий путь сборки и добиться, чтобы в выбранном модуле неверные данные не уходили дальше без проверки. JavaScript синтаксис остаётся законным TypeScript-синтаксисом, а типовые записи исчезают из результата выполнения. Поэтому постепенный путь не обязан ломать действующий runtime.
Нулевая точка: отделяем выпуск от переименования
Сначала записываю, что считается рабочим выпуском до миграции. Это не абстрактное «зелёный CI», а конкретные команды и выходы: существующая сборка, один smoke-маршрут страницы и формат артефакта, который забирает сервер или CDN. Переименование .js в .ts не является доказательством, что этот контракт сохранён. Если вместе с расширением меняются module format, путь импорта и таргет, команда отлаживает три причины сразу и теряет точку возврата.
Следом выбираю один узкий участок, в котором данные меняют владельца: ответ HTTP превращается в модель экрана, параметры формы — в команду API, конфигурация — в объект приложения. На такой границе тип отвечает на вопрос «что именно допускаем дальше». Внутренний вспомогательный файл без входов и выходов можно перенести позже; он не даст раннего сигнала о пользе. Граница удобна ещё и тем, что ошибку можно проверить фикстурой: передать неполный объект и убедиться, что он не попал в типизированный код.
Граница важнее процента файлов
Процент файлов с расширением .ts выглядит удобной метрикой, но он ничего не говорит о данных. Можно перевести сто вспомогательных функций и всё равно пропустить объект ответа без полей id и email в экран. Для первой партии я считаю только названные границы. У каждой есть источник, ожидаемая форма, место проверки и получатель. Если хотя бы одного пункта нет, запись не попадает в счётчик и не создаёт ложного ощущения прогресса.
| Кандидат | Риск без проверки | Минимальный контракт | Первый шаг | Что не менять сейчас |
|---|---|---|---|---|
| Ответ API для карточки | экран ожидает поле, которого сервер не прислал | id, email и status перед рендером | описать вход через JSDoc или TypeScript-функцию нормализации | транспорт, URL и формат production-сборки |
| Параметры формы | строка уходит в команду как неверное значение | состояние формы и команда отправки | сделать явный объект команды рядом с submit | все компоненты формы и стили |
| Конфигурация окружения | пустой ключ даёт сбой уже после выкладки | обязательные строки конфигурации | проверить объект в одном загрузчике | систему secrets и способ доставки переменных |
| Внутренний helper | ошибка редко пересекает модульную границу | локальные аргументы | оставить на вторую очередь | никакой массовой конвертации ради процента |
Это не рейтинг важности компонентов. Это способ не начинать с самого большого каталога. Первый контракт должен быть достаточно мал, чтобы один разработчик объяснил его в ревью, и достаточно близок к внешнему входу, чтобы TypeScript нашёл настоящий класс ошибок. Если сервис возвращает произвольный JSON, тип интерфейса сам по себе не проверит ответ в runtime: значение всё равно приходит из сети. В этом месте нужен явный код проверки, а не уверенность, что объявление type защитило процесс.
Подключаем compiler без остановки JavaScript
Опция allowJs позволяет включать .js рядом с .ts и .tsx. Для перехода это важнее, чем красивый каталог: команда может перевести один модуль, пока его соседи остаются JavaScript. checkJs добавляет диагностику в JavaScript-файлы; её можно включить на весь проект или начать с комментария @ts-check в выбранном файле. Я начинаю локально. Массовое включение проверки превращает первую итерацию в разбор старого долга, а не в проверку новой границы.
Конфигурация ниже — fixture, а не универсальный tsconfig. Она показывает порядок: compiler видит и JavaScript, и TypeScript, но на первом проходе не меняет выходные файлы из-за noEmit. Параметры target и module должны повторять фактические ограничения проекта 2019 года; нельзя подставить их из чужого шаблона и объявить, что выпуск сохранён. Перед merge нужно запустить именно существующую production-команду, а не только tsc.
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"allowJs": true,
"checkJs": false,
"noEmit": true
},
"include": ["src/**/*"]
}
После появления tsconfig полезно проверить две разные вещи. Первая — compiler вообще включает нужный файл: иначе проверка живёт в конфиге, но не действует на границу. Вторая — старый build не стал читать новый output directory или ждать файлы, которые noEmit не создаёт. Я сохраняю оба факта рядом с задачей: команду проверки типов и команду сборки. При откате можно убрать один новый module или комментарий @ts-check, не разбирать последствия переписанной половины дерева.
Проверяем JavaScript прежде, чем переносить его
Небольшой JavaScript-файл уже может дать полезную обратную связь через JSDoc. В fixture ниже функция принимает внешнее значение как unknown и выдаёт Account только после простых проверок. Здесь важен не синтаксис комментариев, а направление потока: неясное значение остаётся на краю, а модульный контракт появляется после проверки. Если заменить вход на any, обращение к record.email перестанет требовать доказательства и граница снова станет прозрачной.
// @ts-check
/**
* @typedef {{ id: string, email: string }} Account
*/
/**
* @param {unknown} value
* @returns {Account | null}
*/
function toAccount(value) {
if (!value || typeof value !== "object") return null;
/** @type {{ [key: string]: unknown }} */
const record = value;
if (typeof record.id !== "string") return null;
if (typeof record.email !== "string") return null;
return { id: record.id, email: record.email };
}
module.exports = { toAccount };
В реальном коде проверка может быть шире: статус, вложенный объект, версия ответа или список записей. Я не пытаюсь описать весь API в первой задаче. Беру поля, от которых зависит текущий экран, и добавляю отрицательную fixture: объект без email должен вернуть null или понятную ошибку. Это даёт ревьюеру проверяемый вопрос: где именно остановятся плохие данные. Позднее контракт можно расширять, не превращая раннюю миграцию в переписывание всего backend-клиента.
Как считать полезное покрытие
Линии с аннотациями не являются метрикой качества. Для очереди миграции я веду маленькую таблицу границ и меняю её только по воспроизводимой проверке. Формула тоже должна оставаться простой: полезное покрытие = число границ, у которых назван источник, контракт, проверка и получатель, делённое на число выбранных границ. Это метод для планирования, а не результат, который можно приписать проекту без списка.
- Источник: откуда приходит значение — HTTP, форма, local storage или конфигурация.
- Контракт: какие поля и варианты нужны следующему модулю именно сейчас.
- Проверка: test fixture или command, на котором неполное значение отклоняется.
- Получатель: функция или компонент, который больше не принимает неясное значение.
- Выпускной gate: действующая команда сборки и smoke-путь, которые запускаются после изменения.
Такой счётчик не скрывает старый JavaScript и не наказует модуль за то, что он ещё не переименован. Он показывает, где TypeScript уже ограничил неопределённость. Когда первая граница стала устойчивой, следующей беру соседнюю: например, список ответов вместо карточки. Резкое включение строгих настроек на весь репозиторий оставляю отдельной задачей с собственным объёмом и временем на исправления.
Порядок первого выпуска
- Зафиксировать существующую build-команду и один пользовательский smoke-сценарий. Записать ожидаемый артефакт и место, где его использует приложение.
- Выбрать одну границу данных, перечислить обязательные поля и добавить отрицательную fixture. Не включать в партию смену module format, роутинга и сборщика.
- Добавить tsconfig с allowJs и noEmit либо согласовать аналогичный безопасный режим в текущей конфигурации. Убедиться, что compiler видит выбранный каталог.
- Поставить @ts-check в один JavaScript-модуль или перенести одну функцию в .ts. Получить конкретную диагностику и исправить её в границе, а не заглушить any.
- Запустить type-check, затем обычную production-сборку и smoke-сценарий. В отчёте отделить факт прохождения команд от предположений о полном покрытии.
- Сделать маленький merge. Следующую границу брать только после того, как понятно, где хранится контракт первой и как откатывается её изменение.
Границы подхода в версии 2019 года
TypeScript 3.5 не превращает JavaScript в проверенный runtime. Компилятор удаляет типовые конструкции из выходного JavaScript, поэтому сеть, JSON и значения от стороннего скрипта остаются внешними входами. Их нужно проверять обычным кодом. Также checkJs может быстро обнаружить старые несогласованности; это сигнал планировать порцию работы, а не причина заменять каждое сообщение на any.
Я бы не обещал в этой итерации «полную строгую типизацию». Цель скромнее и полезнее: рабочая сборка сохранена, у одной важной границы есть контракт, а некорректная fixture не проходит в типизированный модуль. Это оставляет команде следующий шаг, который можно проверить и при необходимости откатить без массовой переделки.
Проверяемые источники
- TypeScript Handbook: Migrating from JavaScript — описывает постепенный переход: JavaScript остаётся входом компилятора при allowJs, а файлы можно переводить по одному
- TSConfig Reference: allowJs — разрешает включать JavaScript-файлы вместе с TypeScript и тем самым не требует одномоментного переименования всего дерева
- TSConfig Reference: checkJs — включает диагностические сообщения для JavaScript-файлов; эквивалентный локальный маркер — комментарий @ts-check
- TypeScript 3.5 release notes — майский релиз 2019 года: в нём зафиксированы улучшения проверки, incremental-сборки и уточнения поведения generic-параметров