Один честный эксперимент на старом JavaScript-проекте выглядит одинаково во многих командах. В понедельник в tsconfig пишут "strict": true, во вторник компилятор выдаёт несколько десятков тысяч ошибок, в среду флаг молча возвращают в false, а в план работ добавляют пункт "когда-нибудь потом". Между тем строгий режим включается постепенно: отдельными опциями, отдельными файлами, с бюджетом на ошибки, который виден в конвейере сборки. Ниже рабочий маршрут миграции кодовой базы с многолетней историей, без заморозки разработки и без массового переписывания.
Цена мгновенного включения strict-режима для старой JavaScript-базы
strict в tsconfig это не один флаг, а связка из десятка проверок: noImplicitAny, strictNullChecks, noImplicitThis, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, useUnknownInCatchVariables и другие. Каждая тянет свой класс правок: где-то нужны аннотации на каждую функцию, где-то проверки на null, где-то честное признание, что поля может не быть. На базе в несколько сотен тысяч строк включение всех опций сразу превращает вывод компилятора в список из десятков тысяч позиций.
Проблема даже не в количестве, а в потере обратной связи. Список в 50 тысяч ошибок невозможно ни разобрать за разумное время, ни измерить прогресс: починишь сотню, а цифра выглядит бесконечной. Правки конфликтуют с разработкой фич, потому что каждый пул-реквест обязан чинить и свои ошибки, и кусок общего долга. Команда выбирает между заморозкой на квартал и полным откатом, и почти всегда выигрывает откат.
Флаг strict годами живёт в положении "вернёмся позже", хотя его ценность именно в старом коде: новые модули обычно пишутся аккуратнее, а легаси копит реальные ошибки обращения к отсутствующим значениям. Постепенная миграция снимает конфликт: ошибки становятся измеримым долгом, который тает от релиза к релизу и не мешает выпускать продукт каждую неделю.
Подготовка компилятора с allowJs и точечным ts-check без переименования файлов
Начало маршрута не требует переименований вообще. TypeScript ставится как dev-зависимость и получает первый tsconfig с двумя ключевыми полями:
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"strict": false,
"noEmit": true,
"module": "commonjs",
"target": "ES2022"
},
"include": ["src/**/*"]
}
Поле allowJs разрешает компилятору видеть существующие js-файлы, checkJs пока оставляет их без проверок, а noEmit не даёт tsc вмешиваться в работающую сборку. Для библиотек без встроенных типов рядом ставят пакеты вида @types/lodash, а для совсем экзотических зависимостей пишут собственный файл объявлений .d.ts на несколько строк.
Первые проверки включаются по одному файлу. Прагма // @ts-check в первой строке js-файла заставляет компилятор проверять именно его, причём типы читаются из JSDoc-комментариев:
// @ts-check
/** @typedef {{id: number, title: string}} Article */
/** @param {Article} article */
function renderCard(article) {
document.querySelector("#card").textContent = article.titel;
}
Компилятор немедленно укажет на свойство titel, которого в описании типа нет. Опечатка такого сорта живёт в старых проектах годами и находится обычно не разработчиком, а пользователем. Прагма даёт команде привыкнуть к инструменту без переименований и без каскада правок в импортах.
У JSDoc достаточно мощности для серьёзных описаний: @typedef заводит именованные структуры, @param описывает параметры, а @satisfies, поддержанный с версии 5.0, разрешает сверить литерал со схемой, не расставляя типов по всему файлу. Так js-файл получает типизацию почти ts-уровня, сохраняя привычный синтаксис, и рецензент видит в диффе только комментарии.
Следующая ступень подготовки это поле checkJs со значением true в паре с прагмой ts-nocheck для исключений. Компилятор начинает проверять все js-файлы, а не только помеченные, волна ошибок вырастает, зато новые опечатки перестают попадать в легаси уже на этом шаге.
Лестница строгости по отдельным флагам от noImplicitAny до strictNullChecks
Опции включают по одной, начиная с дешёвых. noImplicitThis требует явного контекста у функций, где раньше this был неизвестен. strictBindCallApply проверяет сигнатуры при использовании bind, call и apply. Эти флаги дают сотни, а не десятки тысяч ошибок, поэтому подходят для тренировки на реальном коде и первых побед в отчёте сборки.
Самый шумный, но механический флаг это noImplicitAny. Каждый параметр без аннотации получает однотипную ошибку про неявный тип any, и сигнал компилятора невозможно игнорировать: он повторяется на каждой функции. Ошибок много, зато почти каждая правка сводится к добавлению типа в JSDoc или к честному any с пояснением. Отсюда рабочая тактика: флаг включают, когда команда готова к волне механических правок, и закрывают её за пару спринтов.
Типовая правка при noImplicitAny выглядит скромно:
/** @param {{id: number, email: string}} user */
function sendGreeting(user) {
return fetch("/api/greet", {method: "POST", body: JSON.stringify(user)});
}
Параметр перестал быть any, и вызов функции с объектом без поля email компилятор отметит ещё до запуска тестов.
Вершину лестницы занимает strictNullChecks, самый ценный и самый тяжёлый флаг. Именно он отличает реальную типизацию от декоративной: без него null и undefined входят почти в каждый тип, и обращение к user.address.city не вызывает вопросов даже там, где адрес регулярно отсутствует:
const el = document.querySelector("#menu");
el.addEventListener("click", open);
С включённым флагом компилятор честно сообщает, что el может оказаться null, и вынуждает написать проверку или явный обработчик отсутствия элемента. Той же семье принадлежит useUnknownInCatchVariables, вошедший в строгий набор с версии 4.4: переменная ошибки в catch получает тип unknown, и доступ к её свойствам без проверки перестаёт компилироваться.
Миграция файл за файлом с переименованием и временным ts-nocheck
Переименование js в ts включают на отдельно взятых файлах: переименовали, починили ошибки, перешли к следующему. Там, где правки требуют времени, в шапку ставят прагму // @ts-nocheck: файл уже живёт в TypeScript-мире, но проверяется без строгости. Долг становится видимым и считаемым одной командой:
grep -rl "@ts-nocheck" src | wc -l
Чтобы долг не рос, в конвейер ставят бюджет ошибок. Скрипт считает строки с "error TS" и проваливает сборку, когда счётчик превышает эталон:
CURRENT=$(npx tsc --noEmit 2>&1 | grep -c "error TS")
LIMIT=$(cat .ts-error-budget)
if [ "$CURRENT" -gt "$LIMIT" ]; then
echo "бюджет ошибок превышен: $CURRENT вместо $LIMIT"
exit 1
fi
Каждый релиз уменьшает число в файле бюджета, и прогресс виден даже тем, кто миграцией не занимается. Рядом выводится второй счётчик, доля строгих файлов: