8 lines
17 KiB
JSON
8 lines
17 KiB
JSON
{
|
||
"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 + \" <\" + profile.email + \">\";\n}\n\nconst payload: unknown = JSON.parse('{\"id\":\"m-17\"}');\nconst profile = payload as Profile;\nconsole.log(label(profile)); // m-17 <undefined></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 && 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</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<unknown></code>. После проверки экран получает только <code>Member</code>. Если модуль сразу экспортирует <code>any</code>, подсказки на следующем уровне создают видимость контракта, но не защищают ответ сервера.</p>\n<pre><code>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});</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>"
|
||
}
|