Один честный эксперимент на старом 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

Каждый релиз уменьшает число в файле бюджета, и прогресс виден даже тем, кто миграцией не занимается. Рядом выводится второй счётчик, доля строгих файлов:

CODE6 Кодовую базу делят на зоны вложенными конфигурациями: в корне лежит базовый tsconfig, строгие каталоги наследуют его своими надстройками. С версии 5.0, вышедшей 16 марта 2023 года, поле extends принимает массив ссылок, поэтому строгая надстройка собирается из мелких кусков. Версия 5.5 от 20 июня 2024 года добавила переменную ${configDir}, которая чинит главную боль расширяемых конфигураций: пути в базовом файле начали разрешаться относительно него самого, а не относительно наследника.

JSDoc заодно дорос до зрелых решений: в той же версии 5.5 появился тег @import, позволяющий тянуть типы из ts-файлов прямо в js-комментарии, а выводимые предикаты научились понимать фильтрацию вида arr.filter(x => x !== null) без ручных описаний. Старым файлам больше не нужно переименование ради одного только доступа к типам.

Практическая деталь, о которой вспоминают на первом же файле: импорты. После переименования остальные модули продолжают находить файл по прежнему пути, поэтому правки касаются содержимого, а не сотни строк подключений. В новых строгих файлах типовые импорты помечают словами import type, чтобы типы не попадали в итоговый бандл, а JSDoc обходит переименование вовсе через тег @import.

Ошибки продакшена, которые строгий режим ловит до релиза

За сухим списком флагов стоят аварии на живых пользователях. Классика легаси выглядит скромно:

function showUserName(user) {
  label.textContent = user.profile.name;
}

У клиента, зарегистрированного по номеру телефона, поля profile нет. В браузере это падение страницы с трассировкой TypeError о чтении свойства у несуществующего объекта, в службе поддержки пачка одинаковых обращений, на графике ошибок всплеск, который дежурный поначалу помечает как разовый. Строгий режим переносит встречу с проблемой с четверга продакшена на среду локальной машины: компилятор отказывается собирать код, допускающий отсутствие profile, и разработчик обязан выразить это отсутствие явно, через проверку или через тип со смысловым названием.

Опечатки в именах полей настроек, из-за которых переключатель тихо не работал, ловит типизированная структура конфигурации. Несовпадение сигнатур при передаче колбэков в старые jQuery-обёртки подсвечивает strictBindCallApply. Забытая проверка на null перед обращением к элементу интерфейса это вообще самая массовая категория падений фронтенда, и каждая такая строка до включения strictNullChecks просто невидима для инструментов.

Вторая по частоте история это потеря this в обработчиках старого кода. Виджет вешает свой метод на событие без привязки, метод зовёт соседний через this, и падает только у тех пользователей, кто нажимает кнопку: у остальных обработчик ни разу не вызывался. Флаг noImplicitThis подсвечивает такую функцию на этапе сборки, а не в переписке с поддержкой.

Формальная польза тоже есть: код с честными типами читается быстрее, чем код с JSDoc вперемешку, автодополнение в редакторе перестаёт угадывать, а рефакторинг перестаёт быть археологией. Однако решающим аргументом остаются именно аварии, которых не случилось.

Организация командной миграции и критерии конца пути

Правила, при которых переезд доезжает до конца, выведены из практики многих команд:

  1. новые файлы создаются сразу в строгом режиме, обратные переименования запрещены;
  2. переименование старого файла сопровождается починкой его ошибок, массовые переименования вслепую не проводятся;
  3. счётчик ошибок компилятора выводится после каждой сборки и служит единственной метрикой прогресса;
  4. в спринте резервируется фиксированная доля времени на миграцию, обычно пятая часть;
  5. порядок файлов выбирается по риску: сначала оплата, авторизация и импорт, затем утилиты и внутренние тесты;
  6. завершением считается ноль в бюджете ошибок, удержанный два релиза подряд.

Отдельно фиксируется правило для веток: строгий режим не отключается локально даже на время эксперимента. Любое исключение оформляется отдельной задачей с причиной и сроком, иначе через месяц в бюджете окажется пара файлов, о которых никто не помнит.

База в сотни тысяч строк мигрирует кварталами, а не неделями, и это нормальный темп: продукт продолжает выходить, долг монотонно тает, ни одна фича не ждёт великого переезда. Попутно исчезает и сам вопрос сборки: свежие сборки Node.js исполняют TypeScript-файлы напрямую, без отдельного шага транспиляции, так что компилятор медленно переезжает из категории внешних утилит в штатную платформу.

Победа стратегии "файл за файлом" над подходом "всё сразу" не в результате: он одинаков. Победа в наличии промежуточных состояний, в которых продукт продолжают выпускать, а команда видит собственный прогресс числом, а не ощущением. Строгий режим это страховка, а не цель, и включать её целиком или по частям, зависит только от готовности команды остановить разработку на квартал. Ответ почти всегда отрицательный, поэтому и выбирают лестницу вместо прыжка.