diff --git a/editorial/agent-rewrites/296.json b/editorial/agent-rewrites/296.json index 6e3dd4b..6bd32bc 100644 --- a/editorial/agent-rewrites/296.json +++ b/editorial/agent-rewrites/296.json @@ -2,6 +2,6 @@ "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.Представим обычный шаг миграции: несколько файлов переименовали в .ts, редактор показывает подсказки, а сборка проходит. Затем API возвращает объект без email, и экран выводит undefined вместо адреса. Команда добавляет any, ошибка компилятора исчезает, но вместе с ней исчезает и место, где неверное значение должны были остановить. Цена ошибки — не только один сбой: следующий дефект приходится искать по стеку и логам, а не по контракту входа.
Практический вывод такой: TypeScript проверяет связи в исходном коде, но не проверяет форму внешнего объекта во время выполнения. Типы, интерфейсы и аннотации относятся к статической проверке и не превращаются сами по себе в runtime-валидатор. Поэтому миграцию полезно начинать на границе данных: принять внешний вход как unknown, проверить минимальную форму, получить модель и только потом передать её в типизированное ядро.
Компилятор видит тип Profile и может сообщить об опечатке в имени поля внутри TypeScript-кода. Браузер или Node.js не получают это объявление как инструкцию для проверки. После компиляции остаются исполняемые операции: чтение свойств, вызов функций и условия.
type Profile = { id: string; email: string };\n\nfunction label(profile: Profile): string {\n return profile.id + \" <\" + profile.email + \">\";\n}\n\nconst payload: unknown = JSON.parse('{\"id\":\"m-17\"}');\nconst profile = payload as Profile;\nconsole.log(label(profile)); // m-17 <undefined>\nЭтот файл можно скомпилировать обычным tsc. Присваивание as Profile меняет только мнение компилятора: оно не добавляет проверку и не заполняет отсутствующее поле. После компиляции объект остаётся тем же JSON, поэтому пример печатает undefined. Если потребителю нужен отказ, его нужно записать исполняемым кодом.
any пропускает неизвестную форму дальше и переносит ошибку к потребителю.unknown честно сообщает: значение может иметь любую форму. До сужения TypeScript не разрешает читать его свойства, вызывать его как функцию или присваивать его конкретному типу. Это препятствие появляется у входа, где и должна находиться проверка.
any действует наоборот. Он разрешает цепочку вроде payload.user.email.toLowerCase(), даже если ни одно звено не доказано. Это удобно при переносе старого JavaScript, но цена удобства — отключённая проверка для всей цепочки. Оставшийся 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 === null || typeof value !== \"object\" || Array.isArray(value)) {\n return false;\n }\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\nfunction normalizeMember(value: unknown): Member | null {\n return isMember(value) ? value : null;\n}\n\nconst samples: unknown[] = [\n { id: \"m-1\", email: \"one@example.test\", status: \"active\" },\n { id: \"m-2\", email: \"two@example.test\", status: \"archived\" },\n { id: \"m-3\", status: \"active\" },\n];\n\nfor (const sample of samples) {\n const member = normalizeMember(sample);\n console.log(member ? member.id : \"reject\");\n}\n// m-1\n// reject\n// reject\nПроверка намеренно минимальна: она подтверждает только поля, от которых зависит текущая операция. Она принимает дополнительные поля, но отклоняет неверный status, пропущенный email, массив и null. Если бизнес-правило требует непустой адрес или конкретный формат, это отдельное условие. Type guard не доказывает, что сервер вернул правильную бизнес-сущность.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Файл стал .ts, но плохой JSON проходит | Типы не исполняются в runtime | Передать объект без обязательного поля | Добавить предикат и отрицательный тест |
Ошибки исчезли после добавления any | Проверка отключена для цепочки значения | Найти первое присваивание any и его потребителей | Заменить вход на unknown и сузить форму |
| Старый JavaScript включён, но ошибки не видны | allowJs принимает файл, но не обязан диагностировать его | Включить checkJs или локальный // @ts-check | Исправлять сообщения по одному участку |
| После переименования сломался build | Одновременно изменились target, module или output | Сравнить настройки и артефакт с исходной точкой | Вернуть одну ось изменения и повторить build |
Выберите участок, где данные переходят между владельцами: HTTP-ответ становится моделью экрана, форма становится командой API, конфигурация становится объектом приложения. На выбранной границе должны быть четыре явных элемента: источник, минимальная форма, место проверки и получатель. Перевод изолированного helper без внешнего входа даст меньше пользы.
\nТранспорт можно временно оставить JavaScript. В TypeScript-коде важен не суффикс файла, а тип результата адаптера: Promise<unknown>. После проверки экран получает только Member. Если модуль сразу экспортирует any, подсказки на следующем уровне создают видимость контракта, но не защищают ответ сервера.
type LoadMember = (memberId: string) => Promise<unknown>;\n\n// Здесь может быть адаптер старого JavaScript-транспорта.\nconst loadMember: LoadMember = function (memberId) {\n return Promise.resolve({\n id: memberId,\n email: memberId + \"@example.test\",\n status: \"active\",\n });\n};\n\nfunction showMember(memberId: string): Promise<string> {\n return loadMember(memberId).then(function (payload: unknown) {\n const member = normalizeMember(payload);\n if (!member) {\n return \"member-unavailable\";\n }\n return member.email;\n });\n}\n\nshowMember(\"m-17\").then(function (email) {\n console.log(email); // m-17@example.test\n});\nВ этом fixture транспорт возвращает успешный ответ, поэтому результат известен. Для проверки отрицательного пути замените email на числовое значение или status на archived: функция напечатает member-unavailable. В настоящем приложении это может быть состояние ошибки или повторный запрос, но решение должно находиться после проверки, а не до неё.
unknown и написать runtime-проверку обязательных полей и допустимых вариантов.null.allowJs; checkJs включать на понятном участке.any на unknown на выбранном входе и передать дальше только результат предиката.npx --no-install tsc boundary.ts --strict --target es2015 --module commonjs --outDir dist, затем node dist/boundary.js.any записать отдельными долгами с причиной и условием удаления, а не считать их завершённой миграцией.Статические типы не проверяют сервер и не заменяют schema validation, контрактные тесты или наблюдение после выпуска. Runtime-предикат тоже не знает всех бизнес-правил: он подтверждает минимальную форму, нужную текущей операции. Если контракт меняется, проверку нужно обновить вместе с потребителем.
\nДля статьи 2019 года важно зафиксировать версию инструментов. unknown появился в TypeScript 3.0, а настройки target, module, разрешение модулей и поведение сборщика зависят от проекта. Нельзя переносить современный tsconfig.json без сравнения output. Новые diagnostics не следует приписывать одному переименованию файла.
Если внешний API ещё не понятен, лучший первый шаг — не переводить модуль. Оставьте JavaScript, включите локальный @ts-check и зафиксируйте фактический вход. Честно отложенный шов полезнее TypeScript-файла, который заполнен any и пропускает данные без проверки.
Итерация готова, если можно показать четыре результата для одной границы: неполный или неверный payload отклоняется runtime-кодом; типизированный модуль принимает только проверенную модель; type-check сообщает о несовместимом использовании; исходный build и smoke-сценарий проходят с ожидаемым output. Отдельно должен существовать отрицательный тест, иначе зелёная компиляция не доказывает пользу миграции.
\nallowJs, структура входных и выходных файлов и проверка после переименования.unknown, ограничения операций до сужения и отличие от any.checkJs с allowJs и эквивалентность локальной директиве // @ts-check.