From 9663f4e1ff4ce4c967874c602881dc367f223649 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 23:08:24 +0300 Subject: [PATCH] editorial: refine TypeScript migration article 296 --- editorial/agent-rewrites/296.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/editorial/agent-rewrites/296.json b/editorial/agent-rewrites/296.json index 6e3dd4b..6bd32bc 100644 --- a/editorial/agent-rewrites/296.json +++ b/editorial/agent-rewrites/296.json @@ -2,6 +2,6 @@ "index": 296, "slug": "editorial-2019-10-mechanism-typescript-migration", "title": "Миграция на TypeScript: где заканчивается тип и начинается runtime", - "excerpt": "Файл с расширением .ts ещё не защищает приложение от неверного JSON. Разбираем границу между внешним значением и типизированным кодом, роль unknown, риск any и постепенный переход без массового переписывания.", - "contentHtml": "

После переименования нескольких файлов в .ts редактор показывает подсказки, а сборка проходит. Но ответ API с пропущенным email всё равно доходит до экрана и ломает рендер. На следующем шаге появляется any: ошибка компилятора исчезает, однако исчезает и место, где код должен был остановить неверное значение. Цена ошибки — не только один сбой. Команда теряет границу ответственности, а следующий дефект ищет по стеку и логам, а не по контракту входа.

\n

Тезис простой: TypeScript проверяет связи в исходном коде, но не проверяет внешний объект во время выполнения. Типы, интерфейсы и аннотации удаляются из JavaScript. Поэтому миграция должна начинаться с границы данных: принять внешний вход как неизвестный, выполнить runtime-проверку, выпустить небольшую модель и только затем передать её в типизированное ядро. Переименование файлов — лишь способ подключить этот контур, а не доказательство его наличия.

\n

Механизм: что остаётся после компиляции

\n

Компилятор видит тип Profile и может сообщить об опечатке в имени поля. Браузер или Node.js этого типа не видит. После компиляции остаётся исполняемый код: чтение свойств, вызов функций и условия. Если значение пришло из сети, из localStorage или от старой JavaScript-библиотеки, его форма не стала надёжной от соседнего объявления interface.

\n
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}
\n

Этот пример учебный. Он показывает отрицательный путь: если runtime должен отклонить объект без email, в коде нет такого условия. Тип описывает ожидание вызывающего кода, но не доказывает, что сеть это ожидание выполнила.

\n
\"Схема
Типизированное ядро получает модель после проверки. Поток с any пропускает неизвестную форму дальше и переносит ошибку к потребителю.
\n

Почему unknown полезнее any на входе

\n

unknown честно сообщает: значение может иметь любую форму. До сужения TypeScript не разрешает читать его свойства. Это небольшое препятствие появляется в правильном месте — у входа. Автор обязан назвать проверяемые поля и допустимые значения.

\n

any действует наоборот. Он разрешает цепочку вроде payload.user.email.toLowerCase(), даже если ни одно звено не доказано. Ошибка компилятора пропадает, но runtime-защиты не появляется. Временный any допустим только как явно записанный долг: с причиной, владельцем и условием удаления. Без этого он маскирует незавершённую границу.

\n
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}
\n

Проверка намеренно минимальна. Она не пытается описать весь ответ сервера. Она проверяет только поля, от которых зависит текущий экран. Если приходит status: \"archived\", функция возвращает null; вызывающий код должен выбрать состояние ошибки, повторный запрос или безопасное сообщение. Нормализатор не решает UX и не заменяет контракт сервиса. Он не позволяет произвольному объекту притвориться Member.

\n

Симптомы и диагностика

\n
От симптома к проверяемому действию
СимптомПричинаПроверкаДействие
Файл стал .ts, но плохой JSON проходитТипы не исполняются в runtimeПередать объект без обязательного поляДобавить проверку у входа и тест отказа
Ошибки исчезли после добавления anyПроверка отключена для цепочки значенияНайти первое присваивание any и его потребителейЗаменить вход на unknown, сузить форму явно
Включён allowJs, но старый JS молчитРазрешение входных файлов не равно диагностикеДобавить ошибочную операцию в .jsВключить checkJs локально или через конфигурацию
После миграции сломался buildВместе изменились target, module или путь outputСравнить команду и артефакт с исходной точкойВернуть одну ось изменения и проверять прежний build
\n

Постепенный переход через один шов

\n

Выберите участок, где данные переходят между владельцами: HTTP-ответ становится моделью экрана, форма становится командой API, конфигурация становится объектом приложения. Изолированный helper без внешнего входа даст меньше сигнала. На выбранной границе должны быть источник, минимальная форма, место проверки и получатель.

\n

В старом потоке транспорт может остаться JavaScript. Он получает ответ и возвращает тело. Новый TypeScript-модуль принимает unknown, вызывает нормализатор и отдаёт экрану только Member. Так команда меняет один контракт, а не транспорт, экран, bundler и формат модулей одновременно.

\n
// 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}
\n

Код выше — учебная fixture. В ней намеренно не определён транспортный клиент. Она проверяет другое условие: экран не получает response.body напрямую. В настоящем приложении вместо throw может быть объект результата или переход в состояние ошибки. Решение зависит от UI, но неизвестный payload не должен становиться моделью молча.

\n

Порядок действий

\n
  1. Зафиксировать исходную точку: версию TypeScript, команду сборки, формат output и один smoke-сценарий.
  2. Найти первый внешний вход и описать минимальный контракт для конкретного потребителя.
  3. Добавить runtime-проверку на обязательные поля и отрицательную fixture с неполным объектом.
  4. Оставить соседний JavaScript в сборке через allowJs; новый модуль перевести отдельно.
  5. Заменить any на unknown на выбранном входе и сузить значение проверяемым предикатом.
  6. Включить checkJs только на понятном участке: массовый запуск может открыть старый долг вместо проверки новой границы.
  7. Запустить type-check, отрицательный тест нормализатора, прежний build и smoke-сценарий.
  8. Записать оставшиеся any как отдельные долги с причиной и владельцем, а не считать их доказательством завершения.
\n

Ограничения

\n

Статические типы не проверяют сервер и не заменяют schema validation, контрактные тесты или наблюдение после выпуска. Runtime-предикат тоже не знает всех бизнес-правил: он подтверждает минимальную форму, нужную текущей операции. Если контракт меняется, проверку нужно обновить вместе с потребителем.

\n

Исторический проект может использовать TypeScript 3.5 и старый bundler. Нельзя переносить настройки target, module и разрешение модулей из современного шаблона без сравнения output. Обновление компилятора — отдельная ось риска. Новые diagnostics не следует приписывать одному переименованию файла.

\n

Иногда лучший результат первой итерации — не переводить модуль. Если внешний API ещё не понятен, оставьте JavaScript, включите локальный @ts-check и сначала зафиксируйте фактический вход. Честно отложенный шов полезнее TypeScript-файла, который заполнен any и не останавливает данные.

\n

Критерий готовности

\n

Итерация готова, если для выбранной границы можно показать четыре результата: неполный или неверный payload отклоняется runtime-кодом; типизированный модуль принимает только проверенную модель; type-check сообщает об ошибке в несовместимом использовании; исходный build и smoke-сценарий проходят с тем же ожидаемым output. Если есть только зелёная компиляция или только расширение .ts, миграция ещё не доказала пользу.

\n

Проверяемые источники

\n\"" + "excerpt": "Расширение .ts не проверяет JSON во время выполнения. Разбираем границу внешних данных, разницу между unknown и any и постепенный переход на TypeScript через один проверяемый шов.", + "contentHtml": "

Представим обычный шаг миграции: несколько файлов переименовали в .ts, редактор показывает подсказки, а сборка проходит. Затем API возвращает объект без email, и экран выводит undefined вместо адреса. Команда добавляет any, ошибка компилятора исчезает, но вместе с ней исчезает и место, где неверное значение должны были остановить. Цена ошибки — не только один сбой: следующий дефект приходится искать по стеку и логам, а не по контракту входа.

\n

Практический вывод такой: TypeScript проверяет связи в исходном коде, но не проверяет форму внешнего объекта во время выполнения. Типы, интерфейсы и аннотации относятся к статической проверке и не превращаются сами по себе в runtime-валидатор. Поэтому миграцию полезно начинать на границе данных: принять внешний вход как unknown, проверить минимальную форму, получить модель и только потом передать её в типизированное ядро.

\n

Что проверяет компилятор

\n

Компилятор видит тип Profile и может сообщить об опечатке в имени поля внутри TypeScript-кода. Браузер или Node.js не получают это объявление как инструкцию для проверки. После компиляции остаются исполняемые операции: чтение свойств, вызов функций и условия.

\n
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>
\n

Этот файл можно скомпилировать обычным tsc. Присваивание as Profile меняет только мнение компилятора: оно не добавляет проверку и не заполняет отсутствующее поле. После компиляции объект остаётся тем же JSON, поэтому пример печатает undefined. Если потребителю нужен отказ, его нужно записать исполняемым кодом.

\n
\"Схема
Типизированное ядро получает модель после проверки. Поток с any пропускает неизвестную форму дальше и переносит ошибку к потребителю.
\n

Почему unknown безопаснее any на входе

\n

unknown честно сообщает: значение может иметь любую форму. До сужения TypeScript не разрешает читать его свойства, вызывать его как функцию или присваивать его конкретному типу. Это препятствие появляется у входа, где и должна находиться проверка.

\n

any действует наоборот. Он разрешает цепочку вроде payload.user.email.toLowerCase(), даже если ни одно звено не доказано. Это удобно при переносе старого JavaScript, но цена удобства — отключённая проверка для всей цепочки. Оставшийся any стоит считать временным долгом с причиной и способом удаления, а не свидетельством того, что данные стали типизированными.

\n
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
\n

Проверка намеренно минимальна: она подтверждает только поля, от которых зависит текущая операция. Она принимает дополнительные поля, но отклоняет неверный status, пропущенный email, массив и null. Если бизнес-правило требует непустой адрес или конкретный формат, это отдельное условие. Type guard не доказывает, что сервер вернул правильную бизнес-сущность.

\n

Симптомы и проверка границы

\n
От наблюдаемого симптома к проверяемому действию
СимптомПричинаПроверкаДействие
Файл стал .ts, но плохой JSON проходитТипы не исполняются в runtimeПередать объект без обязательного поляДобавить предикат и отрицательный тест
Ошибки исчезли после добавления anyПроверка отключена для цепочки значенияНайти первое присваивание any и его потребителейЗаменить вход на unknown и сузить форму
Старый JavaScript включён, но ошибки не видныallowJs принимает файл, но не обязан диагностировать егоВключить checkJs или локальный // @ts-checkИсправлять сообщения по одному участку
После переименования сломался buildОдновременно изменились target, module или outputСравнить настройки и артефакт с исходной точкойВернуть одну ось изменения и повторить build
\n

Один шов вместо массового переписывания

\n

Выберите участок, где данные переходят между владельцами: HTTP-ответ становится моделью экрана, форма становится командой API, конфигурация становится объектом приложения. На выбранной границе должны быть четыре явных элемента: источник, минимальная форма, место проверки и получатель. Перевод изолированного helper без внешнего входа даст меньше пользы.

\n

Транспорт можно временно оставить JavaScript. В TypeScript-коде важен не суффикс файла, а тип результата адаптера: Promise<unknown>. После проверки экран получает только Member. Если модуль сразу экспортирует any, подсказки на следующем уровне создают видимость контракта, но не защищают ответ сервера.

\n
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});
\n

В этом fixture транспорт возвращает успешный ответ, поэтому результат известен. Для проверки отрицательного пути замените email на числовое значение или status на archived: функция напечатает member-unavailable. В настоящем приложении это может быть состояние ошибки или повторный запрос, но решение должно находиться после проверки, а не до неё.

\n

Порядок постепенной миграции

\n
  1. Зафиксировать исходную точку: версию TypeScript, команду сборки, module, target, output и один smoke-сценарий.
  2. Найти первый внешний вход и описать только ту форму, которая нужна выбранному потребителю.
  3. Принять значение как unknown и написать runtime-проверку обязательных полей и допустимых вариантов.
  4. Добавить отрицательные fixtures: пропущенное поле, неверный тип, неизвестный вариант и null.
  5. Оставить соседний JavaScript в сборке через allowJs; checkJs включать на понятном участке.
  6. Заменить any на unknown на выбранном входе и передать дальше только результат предиката.
  7. Проверить type-check, отрицательные тесты, прежний build и smoke-сценарий. Для локальной fixture подойдут npx --no-install tsc boundary.ts --strict --target es2015 --module commonjs --outDir dist, затем node dist/boundary.js.
  8. Оставшиеся any записать отдельными долгами с причиной и условием удаления, а не считать их завершённой миграцией.
\n

Ограничения и отрицательные пути

\n

Статические типы не проверяют сервер и не заменяют schema validation, контрактные тесты или наблюдение после выпуска. Runtime-предикат тоже не знает всех бизнес-правил: он подтверждает минимальную форму, нужную текущей операции. Если контракт меняется, проверку нужно обновить вместе с потребителем.

\n

Для статьи 2019 года важно зафиксировать версию инструментов. unknown появился в TypeScript 3.0, а настройки target, module, разрешение модулей и поведение сборщика зависят от проекта. Нельзя переносить современный tsconfig.json без сравнения output. Новые diagnostics не следует приписывать одному переименованию файла.

\n

Если внешний API ещё не понятен, лучший первый шаг — не переводить модуль. Оставьте JavaScript, включите локальный @ts-check и зафиксируйте фактический вход. Честно отложенный шов полезнее TypeScript-файла, который заполнен any и пропускает данные без проверки.

\n

Критерий готовности

\n

Итерация готова, если можно показать четыре результата для одной границы: неполный или неверный payload отклоняется runtime-кодом; типизированный модуль принимает только проверенную модель; type-check сообщает о несовместимом использовании; исходный build и smoke-сценарий проходят с ожидаемым output. Отдельно должен существовать отрицательный тест, иначе зелёная компиляция не доказывает пользу миграции.

\n

Проверяемые источники

\n" }