DarkRiDDeR14 мин

Переход на TypeScript: начать с границы, а не с массового any

TypeScriptFrontendПрактика

Симптом миграции легко узнать по pull request: несколько десятков файлов получают расширение .ts, вокруг каждого неудобного вызова появляется any, а выпуск задерживается из-за неясного набора ошибок. Проблема не в самом TypeScript. Команда начала заменять суффиксы файлов, не договорившись, какие данные становятся проверяемыми и где разрешено временно сохранить старый JavaScript. Цена — длинная ветка, в которой сборка то проходит, то нет, а новые типы почти не меняют риск ошибки.

В октябре 2019 года я бы не ставил задачу «перевести приложение на TypeScript». Она не сообщает, где остановиться и как выпускать изменения. Более полезная формулировка: взять один входной контракт, сохранить рабочий путь сборки и добиться, чтобы в выбранном модуле неверные данные не уходили дальше без проверки. JavaScript синтаксис остаётся законным TypeScript-синтаксисом, а типовые записи исчезают из результата выполнения. Поэтому постепенный путь не обязан ломать действующий runtime.

Нулевая точка: отделяем выпуск от переименования

Сначала записываю, что считается рабочим выпуском до миграции. Это не абстрактное «зелёный CI», а конкретные команды и выходы: существующая сборка, один smoke-маршрут страницы и формат артефакта, который забирает сервер или CDN. Переименование .js в .ts не является доказательством, что этот контракт сохранён. Если вместе с расширением меняются module format, путь импорта и таргет, команда отлаживает три причины сразу и теряет точку возврата.

Следом выбираю один узкий участок, в котором данные меняют владельца: ответ HTTP превращается в модель экрана, параметры формы — в команду API, конфигурация — в объект приложения. На такой границе тип отвечает на вопрос «что именно допускаем дальше». Внутренний вспомогательный файл без входов и выходов можно перенести позже; он не даст раннего сигнала о пользе. Граница удобна ещё и тем, что ошибку можно проверить фикстурой: передать неполный объект и убедиться, что он не попал в типизированный код.

Схема постепенной миграции: существующие JavaScript-модули идут в сборку, выбранная граница получает проверку через checkJs и JSDoc, затем один модуль переводится в TypeScript; рабочий выпуск остаётся отдельным контрольным пунктом
Миграция идёт не от каталога к каталогу, а от проверяемой границы к следующей. Сборка остаётся самостоятельным gate на каждом шаге.

Граница важнее процента файлов

Процент файлов с расширением .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 уже ограничил неопределённость. Когда первая граница стала устойчивой, следующей беру соседнюю: например, список ответов вместо карточки. Резкое включение строгих настроек на весь репозиторий оставляю отдельной задачей с собственным объёмом и временем на исправления.

Порядок первого выпуска

  1. Зафиксировать существующую build-команду и один пользовательский smoke-сценарий. Записать ожидаемый артефакт и место, где его использует приложение.
  2. Выбрать одну границу данных, перечислить обязательные поля и добавить отрицательную fixture. Не включать в партию смену module format, роутинга и сборщика.
  3. Добавить tsconfig с allowJs и noEmit либо согласовать аналогичный безопасный режим в текущей конфигурации. Убедиться, что compiler видит выбранный каталог.
  4. Поставить @ts-check в один JavaScript-модуль или перенести одну функцию в .ts. Получить конкретную диагностику и исправить её в границе, а не заглушить any.
  5. Запустить type-check, затем обычную production-сборку и smoke-сценарий. В отчёте отделить факт прохождения команд от предположений о полном покрытии.
  6. Сделать маленький 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-параметров