{ "index": 295, "slug": "editorial-2019-10-field-typescript-migration", "title": "Граница между legacy JavaScript и TypeScript: миграция одного API-потока", "excerpt": "Безопасный переход начинается не с переименования файлов, а с контракта на границе API. Разбираем транспорт, проверку во время выполнения, проверку типов, отрицательный путь и откат для одного старого потока.", "contentHtml": "

Старый JavaScript-модуль загружает участника, возвращает response.body, а экран сразу читает email. Пока сервер отвечает ожидаемым объектом, код выглядит рабочим. После изменения API экран получает undefined или падает в форматтере (коде форматирования). Если начать миграцию с массового переименования файлов, неизвестное внешнее значение (payload) быстро превращается в any. Сборка проходит, но ошибка переезжает дальше по графу. Цена — сломанный экран, трудный откат и новый код, которому компилятор уже не помогает.

\n

Тезис статьи простой: переносите за один раз одну границу данных. Оставьте транспортный код на JavaScript, добавьте TypeScript-нормализатор с входом unknown, а экрану отдавайте только проверенный Member. Для исторического контекста примера зафиксируем TypeScript 3.5 — версию эпохи статьи, а не обязательную версию для нового проекта. Это учебная схема одного API-потока: она не сообщает о production-результатах и не заменяет контрактный тест конкретного сервиса.

\n

Что именно нужно изменить

\n

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

\n

TypeScript проверяет связи между модулями во время сборки. Он не вставляет проверки типов в JavaScript, который приходит по сети. Поэтому тип Member должен появиться после проверки во время выполнения (runtime), а не рядом с необработанным response.body. Это и есть механизм миграции: статическая проверка защищает код после шва, а проверка во время выполнения защищает сам шов.

\n
\"Поток
Один шов между транспортом и экраном позволяет проверять и откатывать миграцию независимо от остального клиента.
\n

Минимальный пример

\n

Сначала оставим старый транспортный код на месте. В настоящем проекте имена request и response зависят от библиотеки. Ниже request передаётся в функцию явно, поэтому сетевой слой можно заменить фиксированной функцией в тесте. Это учебный контекст, а не готовый клиент для копирования.

\n
// api.js\n// @ts-check\n\n/**\n * @param {(url: string) => Promise<{ body: unknown }>} request\n * @param {string} memberId\n * @returns {Promise}\n */\nexport async function loadMember(request, 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> здесь не доказывает форму объекта. Оно только разрешает читать неизвестные ключи после проверки, что значение — объект. Доказательство дают следующие проверки. В примере active и blocked — условные значения домена участника; в рабочем проекте их нужно заменить фактическим контрактом API. Если поле отсутствует или имеет другой тип, функция возвращает null.

\n
// member-screen.ts\nimport { loadMember } from './api.js';\nimport { normalizeMember } from './member';\n\ntype Request = (url: string) => Promise<{ body: unknown }>;\n\nexport async function showMember(\n  request: Request,\n  memberId: string,\n): Promise<string> {\n  const payload = await loadMember(request, memberId);\n  const member = normalizeMember(payload);\n\n  if (!member) return 'Не удалось загрузить участника';\n  return member.email + ' (' + member.status + ')';\n}
\n

В учебном примере экран получает только Member или обрабатывает отказ. В тесте зависимость можно подменить функцией вроде async () => ({ body: valid }), не поднимая настоящий сервер. В рабочем интерфейсе вместо строки может появиться состояние ошибки (error state), повторная загрузка или переход на страницу ошибки. Решение зависит от продукта. Неизменным остаётся условие: экран не читает поля у неясного внешнего значения напрямую.

\n

Почему массовое переименование не решает задачу

\n

Расширение .js на .ts меняет файл, но не источник данных. Компилятор видит объявленный тип, а сетевой ответ остаётся внешним значением без runtime-гарантии. Если разработчик поставит any на ответ, ошибка исчезнет только из отчёта TypeScript. Если включить строгие настройки сразу во всём дереве, команда может получить много несвязанных диагностик и потерять границу первой миграции.

\n

Постепенный путь оставляет JavaScript и TypeScript в одном проекте. allowJs разрешает включать JavaScript-файлы вместе с TypeScript. checkJs добавляет диагностику для JavaScript, а локальный комментарий @ts-check ограничивает первый шаг одним файлом. Эти настройки помогают расширять область проверки, но не создают runtime-валидацию и не описывают неизвестный API автоматически.

\n

Симптом → причина → проверка → действие

\n\n\n\n\n\n\n\n\n\n\n
Диагностика одной миграционной границы
СимптомПричинаПроверкаДействие
Экран падает на body.email.Экран читает необработанный ответ.Поставить fixture без email и пройти путь ошибки.Передать payload через нормализатор.
Новый файл заполнен any.Неясен внешний контракт или его обходят ради сборки.Найти первое место, где значение теряет форму.Описать границу через unknown или отложить поток до исследования API.
tsc проходит, но серверный ответ ломает UI.Статический тип приняли за runtime-проверку.Подать строку, null и объект с неверным статусом.Проверять поля в нормализаторе.
После переименования ломается production-сборка.Изменились target, module, каталог артефактов или порядок конвейера.Сравнить старую команду сборки и артефакты с новой.Вернуть один слой изменения и отдельно запустить проверку типов.
При отказе нечего откатывать.Одновременно заменили transport, bundler и экран.Разделить diff на границы и определить обратную связь.Откатить импорт нормализатора, не трогая transport.
\n

Таблица отделяет вопрос от сигнала. Прошедший type-check отвечает за связи в коде. Fixture отвечает за несколько известных входов. Существующая сборка отвечает за выпускной артефакт. Smoke-сценарий отвечает за один пользовательский путь. Ни один gate не доказывает всё сразу.

\n

Проверка отрицательного пути

\n

Положительный пример показывает только счастливый ответ. Для границы важнее отказ. Минимальный набор учебных входов — корректный объект, объект без email и объект с неизвестным status. Ожидаемые результаты — Member, null, null. Это ожидаемое поведение функции в примере, а не результат запуска настоящего API; такие входы образуют небольшую contract fixture, то есть фиксированный набор для проверки.

\n
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. Не добавляйте эту детализацию в учебный пример без требования продукта. Важно сохранить отрицательный путь видимым и тестируемым.

\n

Порядок действий

\n
    \n
  1. Выберите один API-метод и перечислите поля, которые реально читает экран. Не расширяйте модель предположениями.
  2. \n
  3. Зафиксируйте текущую build-команду, module target и путь артефактов. Это точка сравнения для отката.
  4. \n
  5. Оставьте transport на JavaScript. При необходимости добавьте @ts-check только в этот файл и включите allowJs.
  6. \n
  7. Создайте TypeScript-нормализатор с входом unknown. Сначала проверьте объект, затем обязательные поля и допустимые варианты.
  8. \n
  9. Измените экран так, чтобы он принимал только проверенную модель. Не пропускайте ответ через any ради зелёного type-check.
  10. \n
  11. Проверьте корректный payload и минимум два отрицательных входа. Сохраните ожидаемые результаты рядом с функцией или в тесте.
  12. \n
  13. Запустите выбранный type-check, затем прежнюю сборку, затем smoke-сценарий экрана. Записывайте scope каждой проверки.
  14. \n
  15. Если gate упал, откатите связь нормализатора с экраном. Не меняйте одновременно транспорт и pipeline, пока не найден первый сигнал.
  16. \n
\n

Ограничения и отрицательный путь миграции

\n

Эта схема не исправляет плохой API. Если сервер иногда отдаёт разные формы, нормализатор обнаружит расхождение, но не решит, какая форма правильна. Нужен владелец контракта и отдельное решение о совместимости. Если внешний ответ нельзя проверить без сетевого запроса, добавьте контрактный тест или контролируемый тестовый ответ в инструментах проекта.

\n

Не следует объявлять готовность только потому, что TypeScript-компилятор не показал ошибок. Типы стираются при компиляции. Они не проверяют JSON во время выполнения, не проверяют права доступа и не гарантируют, что браузер отрисует экран. Не следует и включать strict во всём репозитории как замену выбору границы: это может быть отдельная партия с собственным объёмом и планом отката.

\n

Миграцию лучше остановить, если команда не может назвать форму входа, не может повторить отрицательный ответ или не может сохранить старый выпускной маршрут. Оставить такой модуль JavaScript — допустимый результат. Непроверенный TypeScript-слой с any создаёт иллюзию контроля и усложняет следующую попытку.

\n

Критерий готовности

\n

Одна граница готова, когда транспорт остаётся подключаемым к прежнему конвейеру. Экран получает только модель после проверки, корректный вход проходит, а два выбранных отрицательных входа отклоняются. Проверка типов и прежняя сборка проходят; smoke-сценарий показывает ожидаемое состояние. Кроме того, команда должна уметь удалить импорт нормализатора и вернуть старый экран одним небольшим изменением.

\n

Этого критерия достаточно для одной партии. Он не утверждает, что весь проект переведён на TypeScript, что API стабилен или что выпуск безопасен во всех сценариях. Он даёт проверяемый ответ на узкий вопрос: защищена ли выбранная граница данных и можно ли вернуть прежний путь без массового отката.

\n

Проверяемые источники

" }