Files
progcode/editorial/agent-rewrites/296.json
T

8 lines
17 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 и постепенный переход на TypeScript через один проверяемый шов.",
"contentHtml": "<p>Представим обычный шаг миграции: несколько файлов переименовали в <code>.ts</code>, редактор показывает подсказки, а сборка проходит. Затем API возвращает объект без <code>email</code>, и экран выводит <code>undefined</code> вместо адреса. Команда добавляет <code>any</code>, ошибка компилятора исчезает, но вместе с ней исчезает и место, где неверное значение должны были остановить. Цена ошибки — не только один сбой: следующий дефект приходится искать по стеку и логам, а не по контракту входа.</p>\n<p>Практический вывод такой: TypeScript проверяет связи в исходном коде, но не проверяет форму внешнего объекта во время выполнения. Типы, интерфейсы и аннотации относятся к статической проверке и не превращаются сами по себе в runtime-валидатор. Поэтому миграцию полезно начинать на границе данных: принять внешний вход как <code>unknown</code>, проверить минимальную форму, получить модель и только потом передать её в типизированное ядро.</p>\n<h2>Что проверяет компилятор</h2>\n<p>Компилятор видит тип <code>Profile</code> и может сообщить об опечатке в имени поля внутри TypeScript-кода. Браузер или Node.js не получают это объявление как инструкцию для проверки. После компиляции остаются исполняемые операции: чтение свойств, вызов функций и условия.</p>\n<pre><code>type Profile = { id: string; email: string };\n\nfunction label(profile: Profile): string {\n return profile.id + \" &lt;\" + profile.email + \"&gt;\";\n}\n\nconst payload: unknown = JSON.parse('{\"id\":\"m-17\"}');\nconst profile = payload as Profile;\nconsole.log(label(profile)); // m-17 &lt;undefined&gt;</code></pre>\n<p>Этот файл можно скомпилировать обычным <code>tsc</code>. Присваивание <code>as Profile</code> меняет только мнение компилятора: оно не добавляет проверку и не заполняет отсутствующее поле. После компиляции объект остаётся тем же JSON, поэтому пример печатает <code>undefined</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>, даже если ни одно звено не доказано. Это удобно при переносе старого JavaScript, но цена удобства — отключённая проверка для всей цепочки. Оставшийся <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 === 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 &amp;&amp; typeof record.email === \"string\"\n &amp;&amp; (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</code></pre>\n<p>Проверка намеренно минимальна: она подтверждает только поля, от которых зависит текущая операция. Она принимает дополнительные поля, но отклоняет неверный <code>status</code>, пропущенный <code>email</code>, массив и <code>null</code>. Если бизнес-правило требует непустой адрес или конкретный формат, это отдельное условие. Type guard не доказывает, что сервер вернул правильную бизнес-сущность.</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>Старый JavaScript включён, но ошибки не видны</td><td><code>allowJs</code> принимает файл, но не обязан диагностировать его</td><td>Включить <code>checkJs</code> или локальный <code>// @ts-check</code></td><td>Исправлять сообщения по одному участку</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>Promise&lt;unknown&gt;</code>. После проверки экран получает только <code>Member</code>. Если модуль сразу экспортирует <code>any</code>, подсказки на следующем уровне создают видимость контракта, но не защищают ответ сервера.</p>\n<pre><code>type LoadMember = (memberId: string) =&gt; Promise&lt;unknown&gt;;\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&lt;string&gt; {\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});</code></pre>\n<p>В этом fixture транспорт возвращает успешный ответ, поэтому результат известен. Для проверки отрицательного пути замените <code>email</code> на числовое значение или <code>status</code> на <code>archived</code>: функция напечатает <code>member-unavailable</code>. В настоящем приложении это может быть состояние ошибки или повторный запрос, но решение должно находиться после проверки, а не до неё.</p>\n<h2>Порядок постепенной миграции</h2>\n<ol><li>Зафиксировать исходную точку: версию TypeScript, команду сборки, module, target, output и один smoke-сценарий.</li><li>Найти первый внешний вход и описать только ту форму, которая нужна выбранному потребителю.</li><li>Принять значение как <code>unknown</code> и написать runtime-проверку обязательных полей и допустимых вариантов.</li><li>Добавить отрицательные fixtures: пропущенное поле, неверный тип, неизвестный вариант и <code>null</code>.</li><li>Оставить соседний JavaScript в сборке через <code>allowJs</code>; <code>checkJs</code> включать на понятном участке.</li><li>Заменить <code>any</code> на <code>unknown</code> на выбранном входе и передать дальше только результат предиката.</li><li>Проверить type-check, отрицательные тесты, прежний build и smoke-сценарий. Для локальной fixture подойдут <code>npx --no-install tsc boundary.ts --strict --target es2015 --module commonjs --outDir dist</code>, затем <code>node dist/boundary.js</code>.</li><li>Оставшиеся <code>any</code> записать отдельными долгами с причиной и условием удаления, а не считать их завершённой миграцией.</li></ol>\n<h2>Ограничения и отрицательные пути</h2>\n<p>Статические типы не проверяют сервер и не заменяют schema validation, контрактные тесты или наблюдение после выпуска. Runtime-предикат тоже не знает всех бизнес-правил: он подтверждает минимальную форму, нужную текущей операции. Если контракт меняется, проверку нужно обновить вместе с потребителем.</p>\n<p>Для статьи 2019 года важно зафиксировать версию инструментов. <code>unknown</code> появился в TypeScript 3.0, а настройки <code>target</code>, <code>module</code>, разрешение модулей и поведение сборщика зависят от проекта. Нельзя переносить современный <code>tsconfig.json</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. Отдельно должен существовать отрицательный тест, иначе зелёная компиляция не доказывает пользу миграции.</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> — постепенный переход, <code>allowJs</code>, структура входных и выходных файлов и проверка после переименования.</li><li><a href=\"https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-0.html#new-unknown-top-type\" target=\"_blank\" rel=\"noopener noreferrer\">TypeScript 3.0 release notes: New unknown top type</a> — происхождение <code>unknown</code>, ограничения операций до сужения и отличие от <code>any</code>.</li><li><a href=\"https://www.typescriptlang.org/tsconfig/checkJs.html\" target=\"_blank\" rel=\"noopener noreferrer\">TypeScript TSConfig: checkJs</a> — связь <code>checkJs</code> с <code>allowJs</code> и эквивалентность локальной директиве <code>// @ts-check</code>.</li></ul>"
}