{ "index": 297, "slug": "editorial-2019-10-practice-typescript-migration", "title": "Переход на TypeScript: одна проверяемая граница за шаг", "excerpt": "Пошаговый способ перевести один контракт данных на TypeScript: сохранить существующую сборку, проверить внешний JSON и не прятать ошибки за массовым any.", "contentHtml": "
Симптом неудачной миграции виден в pull request: десятки файлов получают расширение .ts, ошибки компилятора закрываются через any, а команда всё ещё не знает, проверяется ли ответ API. Цена ошибки — не только большой diff. Сборка может завершиться успешно, пока экран получает объект без обязательного поля. Дефект обнаружится в браузере или у пользователя, а точка, где исчезла гарантия, уже потеряна.
Переход на TypeScript лучше начинать не с каталога файлов, а с границы данных. Выберите один внешний вход, опишите форму значения, поставьте проверку и передайте результат в новый или уже существующий модуль. При таком порядке JavaScript может оставаться частью проекта. Команда получает конкретный сигнал: неверное значение остановилось, рабочая сборка сохранилась, а новая типизация защищает реальный сценарий.
\nДо изменения зафиксируйте текущий рабочий путь. Запишите команду сборки, папку её результата и короткий smoke-сценарий. Для фронтенда это может быть открытие страницы, загрузка карточки и отправка формы. Такой baseline нужен не для отчётности. Он отделяет проблему миграции от случайного изменения сборщика, формата модулей или маршрута импорта.
\nНе смешивайте в одной итерации четыре изменения: переименование файла, новый module format, замену bundler и строгие настройки всего репозитория. Если после этого перестанет работать импорт, вы не узнаете, какая перемена стала причиной. Первая задача должна оставить старый runtime-путь и изменить только один проверяемый контракт.
\nПолезная первая граница имеет четыре свойства. Известен источник значения. Назван минимальный контракт. Есть код или тест, который отклоняет плохой вход. Понятен получатель результата. Ответ HTTP обычно лучше внутреннего helper, если выбранный экран напрямую зависит от него: ошибка на этой границе видна в сценарии.
\n| Кандидат | Риск без проверки | Минимальный контракт | Первое действие |
|---|---|---|---|
| Ответ API | Экран читает отсутствующее поле | id, email, status | Нормализовать вход перед рендером |
| Параметры формы | Строка уходит в команду как неверное значение | Состояние формы и команда отправки | Собрать явный объект команды |
| Конфигурация | Пустой ключ может остановить запуск | Обязательные строки и допустимые значения | Проверить объект в загрузчике |
| Внутренний helper | Ошибка редко пересекает границу | Локальные аргументы | Оставить на следующую очередь |
Описание типа не проверяет данные, пришедшие по сети. JSON уже существует в runtime до того, как TypeScript увидит результат функции. Поэтому на внешнем краю нужен обычный исполняемый код: проверка типа, набора полей и допустимых значений. После неё тип помогает остальному коду не повторять те же предположения.
\nВ учебном примере ниже проект может включать оба расширения. allowJs позволяет включить существующие JavaScript-файлы рядом с TypeScript. noEmit оставляет выпуск за текущей production-сборкой. Это не готовый конфиг для любого проекта. Значения target, module и include должны соответствовать вашему runtime и структуре исходников.
{ \"compilerOptions\": { \"target\": \"es5\", \"module\": \"commonjs\", \"allowJs\": true, \"checkJs\": false, \"noEmit\": true }, \"include\": [\"src/**/*\"] }\nНа первом шаге не включайте checkJs во всём старом дереве без оценки объёма. Этот флаг сообщает об ошибках в JavaScript-файлах, которые входят в проект. Для точечного старта добавьте // @ts-check в один выбранный файл. Так вы получите ограниченный список диагностик и не превратите миграцию в инвентаризацию всего исторического долга.
Ниже — учебный пример. Он не описывает конкретный API и не утверждает, что такой ответ уже существует в рабочей системе. Функция принимает unknown, проверяет нужные поля и возвращает объект только после успешной проверки. Значение с отсутствующим email не проходит дальше.
type Account = { id: string; email: string; status: \"active\" | \"blocked\" }; function isAccount(value: unknown): value is Account { if (!value || typeof value !== \"object\") return false; const record = value as Record<string, unknown>; return typeof record.id === \"string\" && typeof record.email === \"string\" && (record.status === \"active\" || record.status === \"blocked\"); } export function normalizeAccount(value: unknown): Account | null { return isAccount(value) ? value : null; } const missingEmail: unknown = { id: \"42\", status: \"active\" }; normalizeAccount(missingEmail); // null\nЗдесь есть важная отрицательная ветка. Если сервер вернёт status: \"archived\", функция вернёт null. Дальше приложение должно явно решить, что показывать: сообщение об ошибке, безопасное состояние или повторный запрос. Это решение нельзя заменить утверждением типа. Если заменить unknown на any, компилятор разрешит читать поля без доказательства, и граница снова станет невидимой.
Когда новый TypeScript-модуль импортирует старый JavaScript, типы не делают старый код runtime-безопасным. Они описывают отношения, которые compiler может проверить в исходниках. Сеть, local storage, DOM и callback сторонней библиотеки остаются внешними входами. Их проверяют там, где они входят в собственный контракт.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Сотни ошибок после включения проверки | checkJs включили на всё дерево | Сравнить список файлов и размер диагностики | Оставить локальный @ts-check и выделить отдельную очередь |
| Сборка прошла, карточка падает на поле | Тип описал JSON, но не проверил его | Передать fixture без обязательного поля | Добавить runtime-нормализатор и отрицательный тест |
Ошибки исчезли после добавления any | Неизвестность перенесли в следующий модуль | Найти переходы any через границу | Заменить их на контракт или явно записанный временный долг |
| После переименования сломался импорт | Одновременно изменился путь или формат модулей | Сравнить emitted output и baseline-сборку | Вернуть лишнюю перемену в отдельный шаг |
| Команда считает прогресс по расширениям | Метрика не связана с данными | Для каждого файла назвать вход, контракт и получателя | Считать только подтверждённые границы |
allowJs и безопасный режим вывода. Проверьте, что compiler действительно видит выбранный файл.@ts-check в одном JavaScript-файле или переведите одну функцию в .ts. Не закрывайте новую диагностику массовым any.Постепенная миграция не уменьшает автоматически количество ошибок в старом JavaScript. Она ограничивает область новой проверки. Если модуль вызывают разные страницы с несовместимыми аргументами, сначала нужно найти реальный контракт или разделить адаптеры. Один тип для всех вызовов только скроет различия.
\nНе обещайте полную строгую типизацию по числу переименованных файлов. Не называйте production-готовым учебный tsconfig.json или пример нормализатора. Не включайте строгие флаги на весь репозиторий, если не оценили объём исправлений и не подготовили путь возврата. Массовый any также не является планом отката: он оставляет код исполняемым, но убирает полезную проверку.
Если выбранная граница не даёт воспроизводимой отрицательной проверки, остановитесь. Перенос файла сам по себе не доказывает пользу. Вернитесь к контракту, найдите входное значение и сформулируйте случай, который должен быть отклонён. Если это невозможно сделать без изменения backend, сборщика или публичного API, вынесите зависимость в отдельную задачу.
\nПервая итерация готова, если команда может показать четыре факта: существующая сборка и smoke-сценарий проходят; выбранный файл входит в type-check; неполный или неверный вход не попадает в типизированный модуль; изменение можно откатить без массового восстановления дерева. Число файлов с расширением .ts в этот критерий не входит.
tsconfig, работы с allowJs и перевода файлов по одному.allowJs и эквивалента @ts-check.unknown, any и отсутствия runtime-проверки у утверждений типа.