{ "index": 296, "slug": "editorial-2019-10-mechanism-typescript-migration", "title": "Миграция на TypeScript: где заканчивается тип и начинается runtime", "excerpt": "Файл с расширением .ts ещё не защищает приложение от неверного JSON. Разбираем границу между внешним значением и типизированным кодом, роль unknown, риск any и постепенный переход без массового переписывания.", "contentHtml": "

После переименования нескольких файлов в .ts редактор показывает подсказки, а сборка проходит. Но ответ API с пропущенным email всё равно доходит до экрана и ломает рендер. На следующем шаге появляется any: ошибка компилятора исчезает, однако исчезает и место, где код должен был остановить неверное значение. Цена ошибки — не только один сбой. Команда теряет границу ответственности, а следующий дефект ищет по стеку и логам, а не по контракту входа.

\n

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

\n

Механизм: что остаётся после компиляции

\n

Компилятор видит тип Profile и может сообщить об опечатке в имени поля. Браузер или Node.js этого типа не видит. После компиляции остаётся исполняемый код: чтение свойств, вызов функций и условия. Если значение пришло из сети, из localStorage или от старой JavaScript-библиотеки, его форма не стала надёжной от соседнего объявления interface.

\n
type Profile = { id: string; email: string };\n\nfunction label(profile: Profile): string {\n  return profile.id + \" <\" + profile.email + \">\";\n}\n\n// После компиляции проверка формы исчезает:\nfunction label(profile) {\n  return profile.id + \" <\" + profile.email + \">\";\n}
\n

Этот пример учебный. Он показывает отрицательный путь: если runtime должен отклонить объект без email, в коде нет такого условия. Тип описывает ожидание вызывающего кода, но не доказывает, что сеть это ожидание выполнила.

\n
\"Схема
Типизированное ядро получает модель после проверки. Поток с any пропускает неизвестную форму дальше и переносит ошибку к потребителю.
\n

Почему unknown полезнее any на входе

\n

unknown честно сообщает: значение может иметь любую форму. До сужения TypeScript не разрешает читать его свойства. Это небольшое препятствие появляется в правильном месте — у входа. Автор обязан назвать проверяемые поля и допустимые значения.

\n

any действует наоборот. Он разрешает цепочку вроде payload.user.email.toLowerCase(), даже если ни одно звено не доказано. Ошибка компилятора пропадает, но runtime-защиты не появляется. Временный any допустим только как явно записанный долг: с причиной, владельцем и условием удаления. Без этого он маскирует незавершённую границу.

\n
type Member = {\n  id: string;\n  email: string;\n  status: \"active\" | \"blocked\";\n};\n\nfunction isMember(value: unknown): value is Member {\n  if (!value || typeof value !== \"object\") return false;\n\n  const record = value as { [key: string]: unknown };\n  return typeof record.id === \"string\"\n    && typeof record.email === \"string\"\n    && (record.status === \"active\" || record.status === \"blocked\");\n}\n\nexport function normalizeMember(value: unknown): Member | null {\n  return isMember(value) ? value : null;\n}
\n

Проверка намеренно минимальна. Она не пытается описать весь ответ сервера. Она проверяет только поля, от которых зависит текущий экран. Если приходит status: \"archived\", функция возвращает null; вызывающий код должен выбрать состояние ошибки, повторный запрос или безопасное сообщение. Нормализатор не решает UX и не заменяет контракт сервиса. Он не позволяет произвольному объекту притвориться Member.

\n

Симптомы и диагностика

\n
От симптома к проверяемому действию
СимптомПричинаПроверкаДействие
Файл стал .ts, но плохой JSON проходитТипы не исполняются в runtimeПередать объект без обязательного поляДобавить проверку у входа и тест отказа
Ошибки исчезли после добавления anyПроверка отключена для цепочки значенияНайти первое присваивание any и его потребителейЗаменить вход на unknown, сузить форму явно
Включён allowJs, но старый JS молчитРазрешение входных файлов не равно диагностикеДобавить ошибочную операцию в .jsВключить checkJs локально или через конфигурацию
После миграции сломался buildВместе изменились target, module или путь outputСравнить команду и артефакт с исходной точкойВернуть одну ось изменения и проверять прежний build
\n

Постепенный переход через один шов

\n

Выберите участок, где данные переходят между владельцами: HTTP-ответ становится моделью экрана, форма становится командой API, конфигурация становится объектом приложения. Изолированный helper без внешнего входа даст меньше сигнала. На выбранной границе должны быть источник, минимальная форма, место проверки и получатель.

\n

В старом потоке транспорт может остаться JavaScript. Он получает ответ и возвращает тело. Новый TypeScript-модуль принимает unknown, вызывает нормализатор и отдаёт экрану только Member. Так команда меняет один контракт, а не транспорт, экран, bundler и формат модулей одновременно.

\n
// member-api.js — существующий транспорт\n// @ts-check\n/** @param {string} memberId */\nfunction loadMember(memberId) {\n  return request(\"/members/\" + memberId).then(function (response) {\n    return response.body;\n  });\n}\nmodule.exports = { loadMember };\n\n// member-screen.ts — новый узкий шов\nimport { loadMember } from \"./member-api\";\nimport { normalizeMember } from \"./normalize-member\";\n\nexport function showMember(memberId: string): Promise<string> {\n  return loadMember(memberId).then((payload: unknown) => {\n    const member = normalizeMember(payload);\n    if (!member) throw new Error(\"Ответ не соответствует контракту\");\n    return member.email;\n  });\n}
\n

Код выше — учебная fixture. В ней намеренно не определён транспортный клиент. Она проверяет другое условие: экран не получает response.body напрямую. В настоящем приложении вместо throw может быть объект результата или переход в состояние ошибки. Решение зависит от UI, но неизвестный payload не должен становиться моделью молча.

\n

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

\n
  1. Зафиксировать исходную точку: версию TypeScript, команду сборки, формат output и один smoke-сценарий.
  2. Найти первый внешний вход и описать минимальный контракт для конкретного потребителя.
  3. Добавить runtime-проверку на обязательные поля и отрицательную fixture с неполным объектом.
  4. Оставить соседний JavaScript в сборке через allowJs; новый модуль перевести отдельно.
  5. Заменить any на unknown на выбранном входе и сузить значение проверяемым предикатом.
  6. Включить checkJs только на понятном участке: массовый запуск может открыть старый долг вместо проверки новой границы.
  7. Запустить type-check, отрицательный тест нормализатора, прежний build и smoke-сценарий.
  8. Записать оставшиеся any как отдельные долги с причиной и владельцем, а не считать их доказательством завершения.
\n

Ограничения

\n

Статические типы не проверяют сервер и не заменяют schema validation, контрактные тесты или наблюдение после выпуска. Runtime-предикат тоже не знает всех бизнес-правил: он подтверждает минимальную форму, нужную текущей операции. Если контракт меняется, проверку нужно обновить вместе с потребителем.

\n

Исторический проект может использовать TypeScript 3.5 и старый bundler. Нельзя переносить настройки target, module и разрешение модулей из современного шаблона без сравнения output. Обновление компилятора — отдельная ось риска. Новые diagnostics не следует приписывать одному переименованию файла.

\n

Иногда лучший результат первой итерации — не переводить модуль. Если внешний API ещё не понятен, оставьте JavaScript, включите локальный @ts-check и сначала зафиксируйте фактический вход. Честно отложенный шов полезнее TypeScript-файла, который заполнен any и не останавливает данные.

\n

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

\n

Итерация готова, если для выбранной границы можно показать четыре результата: неполный или неверный payload отклоняется runtime-кодом; типизированный модуль принимает только проверенную модель; type-check сообщает об ошибке в несовместимом использовании; исходный build и smoke-сценарий проходят с тем же ожидаемым output. Если есть только зелёная компиляция или только расширение .ts, миграция ещё не доказала пользу.

\n

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

\n\"" }