Files
progcode/editorial/agent-rewrites/296.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
16 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 296,
"slug": "editorial-2019-10-mechanism-typescript-migration",
"title": "Миграция на TypeScript: где заканчивается тип и начинается runtime",
"excerpt": "Файл с расширением .ts ещё не защищает приложение от неверного JSON. Разбираем границу между внешним значением и типизированным кодом, роль unknown, риск any и постепенный переход без массового переписывания.",
"contentHtml": "<p>После переименования нескольких файлов в <code>.ts</code> редактор показывает подсказки, а сборка проходит. Но ответ API с пропущенным <code>email</code> всё равно доходит до экрана и ломает рендер. На следующем шаге появляется <code>any</code>: ошибка компилятора исчезает, однако исчезает и место, где код должен был остановить неверное значение. Цена ошибки — не только один сбой. Команда теряет границу ответственности, а следующий дефект ищет по стеку и логам, а не по контракту входа.</p>\n<p>Тезис простой: TypeScript проверяет связи в исходном коде, но не проверяет внешний объект во время выполнения. Типы, интерфейсы и аннотации удаляются из JavaScript. Поэтому миграция должна начинаться с границы данных: принять внешний вход как неизвестный, выполнить runtime-проверку, выпустить небольшую модель и только затем передать её в типизированное ядро. Переименование файлов — лишь способ подключить этот контур, а не доказательство его наличия.</p>\n<h2>Механизм: что остаётся после компиляции</h2>\n<p>Компилятор видит тип <code>Profile</code> и может сообщить об опечатке в имени поля. Браузер или Node.js этого типа не видит. После компиляции остаётся исполняемый код: чтение свойств, вызов функций и условия. Если значение пришло из сети, из <code>localStorage</code> или от старой JavaScript-библиотеки, его форма не стала надёжной от соседнего объявления <code>interface</code>.</p>\n<pre><code>type Profile = { id: string; email: string };\n\nfunction label(profile: Profile): string {\n return profile.id + \" &lt;\" + profile.email + \">\";\n}\n\n// После компиляции проверка формы исчезает:\nfunction label(profile) {\n return profile.id + \" &lt;\" + profile.email + \">\";\n}</code></pre>\n<p>Этот пример учебный. Он показывает отрицательный путь: если runtime должен отклонить объект без <code>email</code>, в коде нет такого условия. Тип описывает ожидание вызывающего кода, но не доказывает, что сеть это ожидание выполнила.</p>\n<figure><img src=\"/assets/editorial/2019/typescript-migration-type-boundary-2019.svg\" alt=\"Схема границы TypeScript: внешний payload проходит runtime-проверку и только затем становится Member, а any обходит проверку\" loading=\"lazy\" /><figcaption>Типизированное ядро получает модель после проверки. Поток с <code>any</code> пропускает неизвестную форму дальше и переносит ошибку к потребителю.</figcaption></figure>\n<h2>Почему unknown полезнее any на входе</h2>\n<p><code>unknown</code> честно сообщает: значение может иметь любую форму. До сужения TypeScript не разрешает читать его свойства. Это небольшое препятствие появляется в правильном месте — у входа. Автор обязан назвать проверяемые поля и допустимые значения.</p>\n<p><code>any</code> действует наоборот. Он разрешает цепочку вроде <code>payload.user.email.toLowerCase()</code>, даже если ни одно звено не доказано. Ошибка компилятора пропадает, но runtime-защиты не появляется. Временный <code>any</code> допустим только как явно записанный долг: с причиной, владельцем и условием удаления. Без этого он маскирует незавершённую границу.</p>\n<pre><code>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 &amp;&amp; typeof record.email === \"string\"\n &amp;&amp; (record.status === \"active\" || record.status === \"blocked\");\n}\n\nexport function normalizeMember(value: unknown): Member | null {\n return isMember(value) ? value : null;\n}</code></pre>\n<p>Проверка намеренно минимальна. Она не пытается описать весь ответ сервера. Она проверяет только поля, от которых зависит текущий экран. Если приходит <code>status: \"archived\"</code>, функция возвращает <code>null</code>; вызывающий код должен выбрать состояние ошибки, повторный запрос или безопасное сообщение. Нормализатор не решает UX и не заменяет контракт сервиса. Он не позволяет произвольному объекту притвориться <code>Member</code>.</p>\n<h2>Симптомы и диагностика</h2>\n<div class=\"table-scroll\"><table><caption>От симптома к проверяемому действию</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Файл стал <code>.ts</code>, но плохой JSON проходит</td><td>Типы не исполняются в runtime</td><td>Передать объект без обязательного поля</td><td>Добавить проверку у входа и тест отказа</td></tr><tr><td>Ошибки исчезли после добавления <code>any</code></td><td>Проверка отключена для цепочки значения</td><td>Найти первое присваивание <code>any</code> и его потребителей</td><td>Заменить вход на <code>unknown</code>, сузить форму явно</td></tr><tr><td>Включён <code>allowJs</code>, но старый JS молчит</td><td>Разрешение входных файлов не равно диагностике</td><td>Добавить ошибочную операцию в <code>.js</code></td><td>Включить <code>checkJs</code> локально или через конфигурацию</td></tr><tr><td>После миграции сломался build</td><td>Вместе изменились target, module или путь output</td><td>Сравнить команду и артефакт с исходной точкой</td><td>Вернуть одну ось изменения и проверять прежний build</td></tr></tbody></table></div>\n<h2>Постепенный переход через один шов</h2>\n<p>Выберите участок, где данные переходят между владельцами: HTTP-ответ становится моделью экрана, форма становится командой API, конфигурация становится объектом приложения. Изолированный helper без внешнего входа даст меньше сигнала. На выбранной границе должны быть источник, минимальная форма, место проверки и получатель.</p>\n<p>В старом потоке транспорт может остаться JavaScript. Он получает ответ и возвращает тело. Новый TypeScript-модуль принимает <code>unknown</code>, вызывает нормализатор и отдаёт экрану только <code>Member</code>. Так команда меняет один контракт, а не транспорт, экран, bundler и формат модулей одновременно.</p>\n<pre><code>// 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&lt;string&gt; {\n return loadMember(memberId).then((payload: unknown) =&gt; {\n const member = normalizeMember(payload);\n if (!member) throw new Error(\"Ответ не соответствует контракту\");\n return member.email;\n });\n}</code></pre>\n<p>Код выше — учебная fixture. В ней намеренно не определён транспортный клиент. Она проверяет другое условие: экран не получает <code>response.body</code> напрямую. В настоящем приложении вместо <code>throw</code> может быть объект результата или переход в состояние ошибки. Решение зависит от UI, но неизвестный payload не должен становиться моделью молча.</p>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксировать исходную точку: версию TypeScript, команду сборки, формат output и один smoke-сценарий.</li><li>Найти первый внешний вход и описать минимальный контракт для конкретного потребителя.</li><li>Добавить runtime-проверку на обязательные поля и отрицательную fixture с неполным объектом.</li><li>Оставить соседний JavaScript в сборке через <code>allowJs</code>; новый модуль перевести отдельно.</li><li>Заменить <code>any</code> на <code>unknown</code> на выбранном входе и сузить значение проверяемым предикатом.</li><li>Включить <code>checkJs</code> только на понятном участке: массовый запуск может открыть старый долг вместо проверки новой границы.</li><li>Запустить type-check, отрицательный тест нормализатора, прежний build и smoke-сценарий.</li><li>Записать оставшиеся <code>any</code> как отдельные долги с причиной и владельцем, а не считать их доказательством завершения.</li></ol>\n<h2>Ограничения</h2>\n<p>Статические типы не проверяют сервер и не заменяют schema validation, контрактные тесты или наблюдение после выпуска. Runtime-предикат тоже не знает всех бизнес-правил: он подтверждает минимальную форму, нужную текущей операции. Если контракт меняется, проверку нужно обновить вместе с потребителем.</p>\n<p>Исторический проект может использовать TypeScript 3.5 и старый bundler. Нельзя переносить настройки <code>target</code>, <code>module</code> и разрешение модулей из современного шаблона без сравнения output. Обновление компилятора — отдельная ось риска. Новые diagnostics не следует приписывать одному переименованию файла.</p>\n<p>Иногда лучший результат первой итерации — не переводить модуль. Если внешний API ещё не понятен, оставьте JavaScript, включите локальный <code>@ts-check</code> и сначала зафиксируйте фактический вход. Честно отложенный шов полезнее TypeScript-файла, который заполнен <code>any</code> и не останавливает данные.</p>\n<h2>Критерий готовности</h2>\n<p>Итерация готова, если для выбранной границы можно показать четыре результата: неполный или неверный payload отклоняется runtime-кодом; типизированный модуль принимает только проверенную модель; type-check сообщает об ошибке в несовместимом использовании; исходный build и smoke-сценарий проходят с тем же ожидаемым output. Если есть только зелёная компиляция или только расширение <code>.ts</code>, миграция ещё не доказала пользу.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html\" target=\"_blank\" rel=\"noopener noreferrer\">TypeScript Handbook: Migrating from JavaScript</a> — постепенный переход и совместная обработка JavaScript и TypeScript.</li><li><a href=\"https://www.typescriptlang.org/docs/handbook/2/basic-types.html#unknown\" target=\"_blank\" rel=\"noopener noreferrer\">TypeScript Handbook: Basic Types — unknown</a> — назначение <code>unknown</code> и сужение перед операциями.</li><li><a href=\"https://www.typescriptlang.org/tsconfig/checkJs.html\" target=\"_blank\" rel=\"noopener noreferrer\">TypeScript TSConfig: checkJs</a> — диагностика JavaScript-файлов вместе с <code>allowJs</code>.</li></ul>\""
}