Files
progcode/editorial/agent-rewrites/295.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
18 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": 295,
"slug": "editorial-2019-10-field-typescript-migration",
"title": "Граница между legacy JavaScript и TypeScript: миграция одного API-потока",
"excerpt": "Безопасный переход начинается не с переименования файлов, а с контракта на границе API. Разбираем transport, runtime-проверку, type-check, отрицательный путь и откат для одного legacy-потока.",
"contentHtml": "<p>Legacy-модуль загружает участника, возвращает <code>response.body</code>, а экран сразу читает <code>email</code>. Пока сервер отвечает ожидаемым объектом, код выглядит рабочим. После изменения API экран получает <code>undefined</code> или падает в formatter. Если начать миграцию с массового переименования файлов, неизвестный payload быстро превращается в <code>any</code>. Сборка проходит, но ошибка переезжает дальше по графу. Цена — сломанный экран, трудный откат и новый код, которому компилятор уже не помогает.</p>\n<p>Тезис статьи простой: переносите за один раз одну границу данных. Оставьте транспорт на JavaScript, добавьте TypeScript-нормализатор с входом <code>unknown</code>, а экрану отдавайте только проверенный <code>Member</code>. Это учебная схема для одного API-потока. Она не сообщает о production-результатах и не заменяет контрактный тест конкретного сервиса.</p>\n<h2>Что именно нужно изменить</h2>\n<p>Транспорт отвечает за запрос и ответ библиотеки. Он не должен обещать экрану, что сеть вернула нужную модель. Нормализатор отвечает за форму данных. Он принимает неизвестное значение, проверяет обязательные поля и возвращает либо модель, либо явный отказ. Экран отвечает за отображение успеха и ошибки. Такое разделение даёт каждому слою одну проверяемую обязанность.</p>\n<p>TypeScript проверяет связи между модулями во время сборки. Он не вставляет проверки типов в JavaScript, который приходит по сети. Поэтому тип <code>Member</code> должен появиться после runtime-проверки, а не рядом с необработанным <code>response.body</code>. Это и есть механизм миграции: статическая проверка защищает код после шва, runtime-проверка защищает сам шов.</p>\n<figure><img src=\"/assets/editorial/2019/typescript-migration-release-gates-2019.svg\" alt=\"Поток миграции: JavaScript transport передаёт неизвестный payload TypeScript-нормализатору, затем проверенный Member попадает на экран; рядом показаны fixture, type-check, build и smoke-проверка\" loading=\"lazy\" /><figcaption>Один шов между транспортом и экраном позволяет проверять и откатывать миграцию независимо от остального клиента.</figcaption></figure>\n<h2>Минимальный пример</h2>\n<p>Сначала оставим legacy-транспорт на месте. В настоящем проекте имена <code>request</code> и <code>response</code> зависят от библиотеки. Ниже они обозначают учебный внешний контекст, а не готовый клиент для копирования.</p>\n<pre><code>// api.js\n// @ts-check\nexport async function loadMember(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&lt;string, unknown&gt;;\n if (typeof record.id !== 'string') return null;\n if (typeof record.email !== 'string') return null;\n if (record.status !== 'active' &amp;&amp; record.status !== 'blocked') return null;\n\n return {\n id: record.id,\n email: record.email,\n status: record.status,\n };\n}\n</code></pre>\n<p>Приведение к <code>Record&lt;string, unknown&gt;</code> здесь не доказывает форму объекта. Оно только разрешает читать неизвестные ключи после проверки, что значение — объект. Доказательство дают следующие проверки. Если поле отсутствует или имеет другой тип, функция возвращает <code>null</code>.</p>\n<pre><code>// member-screen.ts\nimport { loadMember } from './api.js';\nimport { normalizeMember } from './member';\n\nexport async function showMember(memberId: string): Promise&lt;string&gt; {\n const payload = await loadMember(memberId);\n const member = normalizeMember(payload);\n\n if (!member) return 'Не удалось загрузить участника';\n return member.email + ' (' + member.status + ')';\n}</code></pre>\n<p>В учебном примере экран получает только <code>Member</code> или обрабатывает отказ. В рабочем интерфейсе вместо строки может появиться error state, повторная загрузка или переход на страницу ошибки. Решение зависит от продукта. Неизменным остаётся условие: экран не читает поля у неясного payload напрямую.</p>\n<h2>Почему массовое переименование не решает задачу</h2>\n<p>Расширение <code>.js</code> на <code>.ts</code> меняет файл, но не источник данных. Компилятор видит объявленный тип, а сервер продолжает присылать bytes. Если разработчик поставит <code>any</code> на ответ, ошибка исчезнет только из отчёта TypeScript. Если включить строгие настройки сразу во всём дереве, команда получит сотни несвязанных диагностик и потеряет границу первой миграции.</p>\n<p>Постепенный путь оставляет JavaScript и TypeScript в одном проекте. <code>allowJs</code> разрешает включать JavaScript-файлы вместе с TypeScript. <code>checkJs</code> добавляет диагностику для JavaScript, а локальный комментарий <code>@ts-check</code> ограничивает первый шаг одним файлом. Эти настройки помогают расширять область проверки, но не создают runtime-валидацию и не описывают неизвестный API автоматически.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table>\n<caption>Диагностика одной миграционной границы</caption>\n<thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead>\n<tbody>\n<tr><td>Экран падает на <code>body.email</code>.</td><td>Экран читает необработанный ответ.</td><td>Поставить fixture без <code>email</code> и пройти путь ошибки.</td><td>Передать payload через нормализатор.</td></tr>\n<tr><td>Новый файл заполнен <code>any</code>.</td><td>Неясен внешний контракт или его обходят ради сборки.</td><td>Найти первое место, где значение теряет форму.</td><td>Описать границу через <code>unknown</code> или отложить поток до исследования API.</td></tr>\n<tr><td><code>tsc</code> проходит, но серверный ответ ломает UI.</td><td>Статический тип приняли за runtime-проверку.</td><td>Подать строку, <code>null</code> и объект с неверным статусом.</td><td>Проверять поля в нормализаторе.</td></tr>\n<tr><td>После rename ломается production build.</td><td>Изменились module target, output path или порядок pipeline.</td><td>Сравнить старую build-команду и артефакты с новой.</td><td>Вернуть один слой изменения и запускать type-check отдельно.</td></tr>\n<tr><td>При отказе нечего откатывать.</td><td>Одновременно заменили transport, bundler и экран.</td><td>Разделить diff на границы и определить обратную связь.</td><td>Откатить импорт нормализатора, не трогая transport.</td></tr>\n</tbody>\n</table>\n<p>Таблица отделяет вопрос от сигнала. Прошедший type-check отвечает за связи в коде. Fixture отвечает за несколько известных входов. Существующая сборка отвечает за выпускной артефакт. Smoke-сценарий отвечает за один пользовательский путь. Ни один gate не доказывает всё сразу.</p>\n<h2>Проверка отрицательного пути</h2>\n<p>Положительный пример показывает только счастливый ответ. Для границы важнее отказ. Минимальный набор учебных входов — корректный объект, объект без <code>email</code> и объект с неизвестным <code>status</code>. Ожидаемые результаты — <code>Member</code>, <code>null</code>, <code>null</code>. Это ожидаемое поведение функции в примере, а не результат запуска настоящего API.</p>\n<pre><code>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</code></pre>\n<p>Если продукт различает «неполный ответ» и «временную ошибку сети», одного <code>null</code> мало. Верните объект с кодом причины или выберите тип <code>Result</code>. Не добавляйте эту детализацию в учебный пример без требования продукта. Важно сохранить отрицательный путь видимым и тестируемым.</p>\n<h2>Порядок действий</h2>\n<ol>\n<li>Выберите один API-метод и перечислите поля, которые реально читает экран. Не расширяйте модель предположениями.</li>\n<li>Зафиксируйте текущую build-команду, module target и путь артефактов. Это точка сравнения для отката.</li>\n<li>Оставьте transport на JavaScript. При необходимости добавьте <code>@ts-check</code> только в этот файл и включите <code>allowJs</code>.</li>\n<li>Создайте TypeScript-нормализатор с входом <code>unknown</code>. Сначала проверьте объект, затем обязательные поля и допустимые варианты.</li>\n<li>Измените экран так, чтобы он принимал только проверенную модель. Не пропускайте ответ через <code>any</code> ради зелёного type-check.</li>\n<li>Проверьте корректный payload и минимум два отрицательных входа. Сохраните ожидаемые результаты рядом с функцией или в тесте.</li>\n<li>Запустите выбранный type-check, затем прежнюю сборку, затем smoke-сценарий экрана. Записывайте scope каждой проверки.</li>\n<li>Если gate упал, откатите связь нормализатора с экраном. Не меняйте одновременно транспорт и pipeline, пока не найден первый сигнал.</li>\n</ol>\n<h2>Ограничения и отрицательный путь миграции</h2>\n<p>Эта схема не исправляет плохой API. Если сервер иногда отдаёт разные формы, нормализатор обнаружит расхождение, но не решит, какая форма правильна. Нужен владелец контракта и отдельное решение о совместимости. Если внешний ответ нельзя проверить без сетевого запроса, добавьте контрактный тест или контролируемый тестовый ответ в инструментах проекта.</p>\n<p>Не следует объявлять готовность только потому, что TypeScript-компилятор не показал ошибок. Типы стираются при компиляции. Они не проверяют JSON во время выполнения, не проверяют права доступа и не гарантируют, что браузер отрисует экран. Не следует и включать <code>strict</code> во всём репозитории как замену выбору границы: это может быть отдельная партия с собственным объёмом и планом отката.</p>\n<p>Миграцию лучше остановить, если команда не может назвать форму входа, не может повторить отрицательный ответ или не может сохранить старый выпускной маршрут. Оставить такой модуль JavaScript — допустимый результат. Непроверенный TypeScript-слой с <code>any</code> создаёт иллюзию контроля и усложняет следующую попытку.</p>\n<h2>Критерий готовности</h2>\n<p>Одна граница готова, когда transport остаётся подключаемым к прежнему pipeline, экран получает только модель после проверки, корректный вход проходит, два выбранных отрицательных входа отклоняются, type-check и прежняя сборка проходят, а smoke-сценарий показывает ожидаемое состояние. Кроме того, команда должна уметь удалить импорт нормализатора и вернуть старый экран одним небольшим изменением.</p>\n<p>Этого критерия достаточно для одной партии. Он не утверждает, что весь проект переведён на TypeScript, что API стабилен или что выпуск безопасен во всех сценариях. Он даёт проверяемый ответ на узкий вопрос: защищена ли выбранная граница данных и можно ли вернуть прежний путь без массового отката.</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/tsconfig/allowJs.html\" target=\"_blank\" rel=\"noopener noreferrer\">TypeScript TSConfig: allowJs</a> — включение JavaScript-файлов в проект TypeScript.</li><li><a href=\"https://www.typescriptlang.org/tsconfig/checkJs.html\" target=\"_blank\" rel=\"noopener noreferrer\">TypeScript TSConfig: checkJs</a> — диагностика JavaScript-файлов и связь с <code>@ts-check</code>.</li></ul>"
}