8 lines
16 KiB
JSON
8 lines
16 KiB
JSON
{
|
||
"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 + \" <\" + profile.email + \">\";\n}\n\n// После компиляции проверка формы исчезает:\nfunction label(profile) {\n return profile.id + \" <\" + 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 && 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}</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<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}</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>\""
|
||
}
|