{ "index": 296, "slug": "editorial-2019-10-mechanism-typescript-migration", "title": "Миграция на TypeScript: где заканчивается тип и начинается runtime", "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" }