{ "index": 296, "slug": "editorial-2019-10-mechanism-typescript-migration", "title": "Миграция на TypeScript: где заканчивается тип и начинается runtime", "excerpt": "Файл с расширением .ts ещё не защищает приложение от неверного JSON. Разбираем границу между внешним значением и типизированным кодом, роль unknown, риск any и постепенный переход без массового переписывания.", "contentHtml": "
После переименования нескольких файлов в .ts редактор показывает подсказки, а сборка проходит. Но ответ API с пропущенным email всё равно доходит до экрана и ломает рендер. На следующем шаге появляется any: ошибка компилятора исчезает, однако исчезает и место, где код должен был остановить неверное значение. Цена ошибки — не только один сбой. Команда теряет границу ответственности, а следующий дефект ищет по стеку и логам, а не по контракту входа.
Тезис простой: TypeScript проверяет связи в исходном коде, но не проверяет внешний объект во время выполнения. Типы, интерфейсы и аннотации удаляются из JavaScript. Поэтому миграция должна начинаться с границы данных: принять внешний вход как неизвестный, выполнить runtime-проверку, выпустить небольшую модель и только затем передать её в типизированное ядро. Переименование файлов — лишь способ подключить этот контур, а не доказательство его наличия.
\nКомпилятор видит тип Profile и может сообщить об опечатке в имени поля. Браузер или Node.js этого типа не видит. После компиляции остаётся исполняемый код: чтение свойств, вызов функций и условия. Если значение пришло из сети, из localStorage или от старой JavaScript-библиотеки, его форма не стала надёжной от соседнего объявления interface.
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, в коде нет такого условия. Тип описывает ожидание вызывающего кода, но не доказывает, что сеть это ожидание выполнила.
any пропускает неизвестную форму дальше и переносит ошибку к потребителю.unknown честно сообщает: значение может иметь любую форму. До сужения TypeScript не разрешает читать его свойства. Это небольшое препятствие появляется в правильном месте — у входа. Автор обязан назвать проверяемые поля и допустимые значения.
any действует наоборот. Он разрешает цепочку вроде payload.user.email.toLowerCase(), даже если ни одно звено не доказано. Ошибка компилятора пропадает, но runtime-защиты не появляется. Временный any допустим только как явно записанный долг: с причиной, владельцем и условием удаления. Без этого он маскирует незавершённую границу.
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.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Файл стал .ts, но плохой JSON проходит | Типы не исполняются в runtime | Передать объект без обязательного поля | Добавить проверку у входа и тест отказа |
Ошибки исчезли после добавления any | Проверка отключена для цепочки значения | Найти первое присваивание any и его потребителей | Заменить вход на unknown, сузить форму явно |
Включён allowJs, но старый JS молчит | Разрешение входных файлов не равно диагностике | Добавить ошибочную операцию в .js | Включить checkJs локально или через конфигурацию |
| После миграции сломался build | Вместе изменились target, module или путь output | Сравнить команду и артефакт с исходной точкой | Вернуть одну ось изменения и проверять прежний build |
Выберите участок, где данные переходят между владельцами: HTTP-ответ становится моделью экрана, форма становится командой API, конфигурация становится объектом приложения. Изолированный helper без внешнего входа даст меньше сигнала. На выбранной границе должны быть источник, минимальная форма, место проверки и получатель.
\nВ старом потоке транспорт может остаться JavaScript. Он получает ответ и возвращает тело. Новый TypeScript-модуль принимает unknown, вызывает нормализатор и отдаёт экрану только Member. Так команда меняет один контракт, а не транспорт, экран, bundler и формат модулей одновременно.
// 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 не должен становиться моделью молча.
allowJs; новый модуль перевести отдельно.any на unknown на выбранном входе и сузить значение проверяемым предикатом.checkJs только на понятном участке: массовый запуск может открыть старый долг вместо проверки новой границы.any как отдельные долги с причиной и владельцем, а не считать их доказательством завершения.Статические типы не проверяют сервер и не заменяют schema validation, контрактные тесты или наблюдение после выпуска. Runtime-предикат тоже не знает всех бизнес-правил: он подтверждает минимальную форму, нужную текущей операции. Если контракт меняется, проверку нужно обновить вместе с потребителем.
\nИсторический проект может использовать TypeScript 3.5 и старый bundler. Нельзя переносить настройки target, module и разрешение модулей из современного шаблона без сравнения output. Обновление компилятора — отдельная ось риска. Новые diagnostics не следует приписывать одному переименованию файла.
Иногда лучший результат первой итерации — не переводить модуль. Если внешний API ещё не понятен, оставьте JavaScript, включите локальный @ts-check и сначала зафиксируйте фактический вход. Честно отложенный шов полезнее TypeScript-файла, который заполнен any и не останавливает данные.
Итерация готова, если для выбранной границы можно показать четыре результата: неполный или неверный payload отклоняется runtime-кодом; типизированный модуль принимает только проверенную модель; type-check сообщает об ошибке в несовместимом использовании; исходный build и smoke-сценарий проходят с тем же ожидаемым output. Если есть только зелёная компиляция или только расширение .ts, миграция ещё не доказала пользу.
unknown и сужение перед операциями.allowJs.