{ "index": 295, "slug": "editorial-2019-10-field-typescript-migration", "title": "Граница между legacy JavaScript и TypeScript: миграция одного API-потока", "excerpt": "Безопасный переход начинается не с переименования файлов, а с контракта на границе API. Разбираем transport, runtime-проверку, type-check, отрицательный путь и откат для одного legacy-потока.", "contentHtml": "
Legacy-модуль загружает участника, возвращает response.body, а экран сразу читает email. Пока сервер отвечает ожидаемым объектом, код выглядит рабочим. После изменения API экран получает undefined или падает в formatter. Если начать миграцию с массового переименования файлов, неизвестный payload быстро превращается в any. Сборка проходит, но ошибка переезжает дальше по графу. Цена — сломанный экран, трудный откат и новый код, которому компилятор уже не помогает.
Тезис статьи простой: переносите за один раз одну границу данных. Оставьте транспорт на JavaScript, добавьте TypeScript-нормализатор с входом unknown, а экрану отдавайте только проверенный Member. Это учебная схема для одного API-потока. Она не сообщает о production-результатах и не заменяет контрактный тест конкретного сервиса.
Транспорт отвечает за запрос и ответ библиотеки. Он не должен обещать экрану, что сеть вернула нужную модель. Нормализатор отвечает за форму данных. Он принимает неизвестное значение, проверяет обязательные поля и возвращает либо модель, либо явный отказ. Экран отвечает за отображение успеха и ошибки. Такое разделение даёт каждому слою одну проверяемую обязанность.
\nTypeScript проверяет связи между модулями во время сборки. Он не вставляет проверки типов в JavaScript, который приходит по сети. Поэтому тип Member должен появиться после runtime-проверки, а не рядом с необработанным response.body. Это и есть механизм миграции: статическая проверка защищает код после шва, runtime-проверка защищает сам шов.
Сначала оставим legacy-транспорт на месте. В настоящем проекте имена request и response зависят от библиотеки. Ниже они обозначают учебный внешний контекст, а не готовый клиент для копирования.
// api.js\n// @ts-check\nexport async function loadMember(memberId) {\n const response = await request('/members/' + memberId);\n return response.body;\n}\n\n// member.ts\nexport type Member = {\n id: string;\n email: string;\n status: 'active' | 'blocked';\n};\n\nexport function normalizeMember(value: unknown): Member | null {\n if (!value || typeof value !== 'object') return null;\n\n const record = value as Record<string, unknown>;\n if (typeof record.id !== 'string') return null;\n if (typeof record.email !== 'string') return null;\n if (record.status !== 'active' && record.status !== 'blocked') return null;\n\n return {\n id: record.id,\n email: record.email,\n status: record.status,\n };\n}\n\nПриведение к Record<string, unknown> здесь не доказывает форму объекта. Оно только разрешает читать неизвестные ключи после проверки, что значение — объект. Доказательство дают следующие проверки. Если поле отсутствует или имеет другой тип, функция возвращает null.
// member-screen.ts\nimport { loadMember } from './api.js';\nimport { normalizeMember } from './member';\n\nexport async function showMember(memberId: string): Promise<string> {\n const payload = await loadMember(memberId);\n const member = normalizeMember(payload);\n\n if (!member) return 'Не удалось загрузить участника';\n return member.email + ' (' + member.status + ')';\n}\nВ учебном примере экран получает только Member или обрабатывает отказ. В рабочем интерфейсе вместо строки может появиться error state, повторная загрузка или переход на страницу ошибки. Решение зависит от продукта. Неизменным остаётся условие: экран не читает поля у неясного payload напрямую.
Расширение .js на .ts меняет файл, но не источник данных. Компилятор видит объявленный тип, а сервер продолжает присылать bytes. Если разработчик поставит any на ответ, ошибка исчезнет только из отчёта TypeScript. Если включить строгие настройки сразу во всём дереве, команда получит сотни несвязанных диагностик и потеряет границу первой миграции.
Постепенный путь оставляет JavaScript и TypeScript в одном проекте. allowJs разрешает включать JavaScript-файлы вместе с TypeScript. checkJs добавляет диагностику для JavaScript, а локальный комментарий @ts-check ограничивает первый шаг одним файлом. Эти настройки помогают расширять область проверки, но не создают runtime-валидацию и не описывают неизвестный API автоматически.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Экран падает на body.email. | Экран читает необработанный ответ. | Поставить fixture без email и пройти путь ошибки. | Передать payload через нормализатор. |
Новый файл заполнен any. | Неясен внешний контракт или его обходят ради сборки. | Найти первое место, где значение теряет форму. | Описать границу через unknown или отложить поток до исследования API. |
tsc проходит, но серверный ответ ломает UI. | Статический тип приняли за runtime-проверку. | Подать строку, null и объект с неверным статусом. | Проверять поля в нормализаторе. |
| После rename ломается production build. | Изменились module target, output path или порядок pipeline. | Сравнить старую build-команду и артефакты с новой. | Вернуть один слой изменения и запускать type-check отдельно. |
| При отказе нечего откатывать. | Одновременно заменили transport, bundler и экран. | Разделить diff на границы и определить обратную связь. | Откатить импорт нормализатора, не трогая transport. |
Таблица отделяет вопрос от сигнала. Прошедший type-check отвечает за связи в коде. Fixture отвечает за несколько известных входов. Существующая сборка отвечает за выпускной артефакт. Smoke-сценарий отвечает за один пользовательский путь. Ни один gate не доказывает всё сразу.
\nПоложительный пример показывает только счастливый ответ. Для границы важнее отказ. Минимальный набор учебных входов — корректный объект, объект без email и объект с неизвестным status. Ожидаемые результаты — Member, null, null. Это ожидаемое поведение функции в примере, а не результат запуска настоящего API.
const valid = {\n id: 'm-1',\n email: 'user@example.test',\n status: 'active',\n};\n\nnormalizeMember(valid); // Member\nnormalizeMember({ id: 'm-1', status: 'active' }); // null\nnormalizeMember({ ...valid, status: 'pending' }); // null\nЕсли продукт различает «неполный ответ» и «временную ошибку сети», одного null мало. Верните объект с кодом причины или выберите тип Result. Не добавляйте эту детализацию в учебный пример без требования продукта. Важно сохранить отрицательный путь видимым и тестируемым.
@ts-check только в этот файл и включите allowJs.unknown. Сначала проверьте объект, затем обязательные поля и допустимые варианты.any ради зелёного type-check.Эта схема не исправляет плохой API. Если сервер иногда отдаёт разные формы, нормализатор обнаружит расхождение, но не решит, какая форма правильна. Нужен владелец контракта и отдельное решение о совместимости. Если внешний ответ нельзя проверить без сетевого запроса, добавьте контрактный тест или контролируемый тестовый ответ в инструментах проекта.
\nНе следует объявлять готовность только потому, что TypeScript-компилятор не показал ошибок. Типы стираются при компиляции. Они не проверяют JSON во время выполнения, не проверяют права доступа и не гарантируют, что браузер отрисует экран. Не следует и включать strict во всём репозитории как замену выбору границы: это может быть отдельная партия с собственным объёмом и планом отката.
Миграцию лучше остановить, если команда не может назвать форму входа, не может повторить отрицательный ответ или не может сохранить старый выпускной маршрут. Оставить такой модуль JavaScript — допустимый результат. Непроверенный TypeScript-слой с any создаёт иллюзию контроля и усложняет следующую попытку.
Одна граница готова, когда transport остаётся подключаемым к прежнему pipeline, экран получает только модель после проверки, корректный вход проходит, два выбранных отрицательных входа отклоняются, type-check и прежняя сборка проходят, а smoke-сценарий показывает ожидаемое состояние. Кроме того, команда должна уметь удалить импорт нормализатора и вернуть старый экран одним небольшим изменением.
\nЭтого критерия достаточно для одной партии. Он не утверждает, что весь проект переведён на TypeScript, что API стабилен или что выпуск безопасен во всех сценариях. Он даёт проверяемый ответ на узкий вопрос: защищена ли выбранная граница данных и можно ли вернуть прежний путь без массового отката.
\n@ts-check.