DarkRiDDeR15 мин

Разбор: как перевести один legacy-поток на TypeScript и не сорвать выпуск

TypeScriptFrontendПолевой разбор

Проблема типичного legacy-потока выглядит так: JavaScript-модуль загружает карточку участника, возвращает response.body и сразу передаёт его в экран. При попытке миграции команда меняет все соседние файлы на .ts, встречает неясный payload и пишет any. Сборка может пройти, но блокировка доставки остаётся: неизвестно, где проверять новый ответ сервера и как откатить изменение, если production-команда начнёт брать другой output. Цена — ветка с большим количеством механических изменений и без одного места, которое отвечает за форму данных.

Разберём маленькую fixture, а не реальный проект. Её цель — показать маршрут, который можно повторить на одном API-методе в октябре 2019 года: transport остаётся JavaScript, новый normalizer получает TypeScript, экран принимает только Member, а сборка проверяется отдельно. В fixture нет выдуманного выигрыша в скорости или процента покрытия. Есть наблюдаемые условия: неполный payload отклоняется, compiler видит связи модулей, прежняя build-команда остаётся последним gate перед merge.

Исходный поток и место, где теряется контракт

В старом коде loadMember возвращает то, что лежит в response.body. Эта функция не обязана знать всю модель экрана: она отвечает за транспорт. Ошибка начинается, когда экран обращается к body.email так, словно сеть уже доказала наличие строки. Если response.body изменился или сервер вернул ошибочный объект, сбой проявится далеко от входа. Для миграции нужен не полный rewrite клиента, а одно новое место между transport и экраном.

Этим местом будет normalizeMember. На вход она принимает unknown, на выход возвращает Member или null. Такой выбор специально делает обработку отсутствующего контракта видимой в showMember: автор не может случайно отдать null в formatter. Если продукту нужно показать экран ошибки, он выбирает его там, где есть контекст страницы. Normalizer не решает UX, он лишь не выдаёт произвольный payload за известную модель.

Схема fixture: JavaScript transport возвращает неясный payload, TypeScript normalizer проверяет его и отдаёт Member экрану; рядом четыре независимых gate — fixture неверного ответа, type-check, прежняя production-сборка и smoke-сценарий
Разделение помогает откатить один TypeScript-модуль, не меняя транспорт и выпускной маршрут одновременно.

Минимальный шов между JavaScript и TypeScript

Первый файл можно не переименовывать. В нём появляется локальный @ts-check, чтобы compiler мог хотя бы проверить аргумент memberId и ожидаемую форму возвращаемой цепочки. Новый файл normalizer пишется на .ts. Этот шаг соответствует allowJs: JavaScript и TypeScript живут в одном проекте. Он не требует немедленной смены module resolution или перехода на другой bundler, что особенно важно, когда доставка уже завязана на старую конфигурацию.

// member-api.js остаётся JavaScript на первом шаге.
// @ts-check

/** @param {string} memberId */
function loadMember(memberId) {
  return request("/members/" + memberId).then(function(response) {
    return response.body;
  });
}

module.exports = { loadMember };

// member-screen.ts — новый узкий TypeScript-модуль.
import { loadMember } from "./member-api";
import { normalizeMember } from "./normalize-member";

export function showMember(memberId: string): Promise<string> {
  return loadMember(memberId).then(function(payload) {
    const member = normalizeMember(payload);
    if (!member) throw new Error("Ответ участника не соответствует контракту");
    return member.email;
  });
}

В примере строка Promise<string> экранирована в HTML, но смысл обычный: showMember обещает строку только после успешной нормализации. Если payload не соответствует контракту, функция бросает понятную ошибку. В настоящем продукте вместо throw может быть Result-подобный объект или переход в error state; статья не навязывает один способ. Проверяемое условие одно: экран не получает неясный response.body напрямую.

Обратите внимание, что @ts-check не делает transport идеальным. request и response здесь намеренно не определены: это внешний legacy-контекст fixture. Для первой партии достаточно не добавлять any в новый шов и не заставлять normalizer угадывать устройство сетевой библиотеки. Когда станет ясно, какой контракт возвращает request, его можно описать отдельной задачей. Так очередь миграции растёт по границам, а не по количеству строк, которые удалось переименовать.

Проверка runtime-входа в отдельной функции

Следующий фрагмент — единственное место, где fixture признаёт конкретную форму участника. Он использует возможности TypeScript, доступные к 3.5: unknown, string literal types и type predicate. При этом условия typeof и сравнения статусов — обычный JavaScript, который останется после компиляции. Поэтому отрицательная fixture может проверить их без надежды на то, что type alias материализуется в runtime.

type Member = {
  id: string;
  email: string;
  status: "active" | "blocked";
};

function isMember(value: unknown): value is Member {
  if (!value || typeof value !== "object") return false;
  const record = value as { [key: string]: unknown };
  return typeof record.id === "string"
    && typeof record.email === "string"
    && (record.status === "active" || record.status === "blocked");
}

export function normalizeMember(value: unknown): Member | null {
  return isMember(value) ? value : null;
}

Для проверки файла достаточно трёх входов: корректный объект, объект без email и объект с неизвестным status. Ожидаемые результаты — Member, null, null. Это не результат запуска production-сервиса, а contract fixture, которую команда может положить рядом с модулем или выполнить в unit-тесте, когда в репозитории есть подходящий runner. В ревью нельзя заменять её фразой «сервер всегда так отвечает»: именно изменение этого предположения обычно и создаёт ошибку.

Выпускные gates для одной migration-партии
GateВходОжидаемое наблюдениеЧто это не доказываетДействие при сбое
Contract fixtureвалидный и два неполных payloadнеподходящее значение не превращается в Memberне проверяет живой сервер и сетевой маршрутуточнить normalizer или согласовать API-контракт
Type-checkjs/ts файлы выбранной границыcompiler видит импорт, аргумент и возвращаемое значениене создаёт runtime-валидацию сам по себеубрать any, сузить вход либо сузить объём партии
Существующий buildобычная production-командаполучается прежний ожидаемый артефактне подтверждает все пользовательские сценариисравнить config, output path и порядок шагов
Smoke-сценарийтестовый ответ и экран участникаизвестный Member отображается через новый шовне заменяет нагрузочный и интеграционный тествернуть маленький commit или отключить новый путь до расследования

Таблица специально отделяет наблюдение от обещания. Прошедший tsc не доказывает, что API стабилен. Прошедший build не доказывает, что screen reader или все браузеры обработают страницу одинаково. Но каждый gate отвечает на свой вопрос и не позволяет спрятать отказ под общим словом «миграция завершена». Это делает отчёт коротким: команда знает, какой вход проверяли, какой сигнал получили и чего ещё не проверяли.

Как сохранить работающую сборку

Перед изменением я записываю текущую команду сборки и путь её артефактов. Если проект уже использует другой transpiler для JavaScript, TypeScript можно сначала запускать с noEmit как отдельный checker. Если tsc должен генерировать JavaScript, output directory и module target нужно сравнить с тем, что забирает existing pipeline. Нельзя одновременно сменить расширения, compiler, выходной каталог и способ загрузки модулей: при сбое не останется маленького изменения для отката.

В TypeScript 3.5 есть улучшения incremental-сборки, но сама опция не является обязательной частью первой миграции. Быстрая проверка полезна только после того, как команда понимает, что именно ей измерять: одинаковый набор файлов, одна версия compiler и отдельный report для production build. В fixture я не объявляю время сборки и не делаю вывод о скорости. Сначала нужно получить повторяемые команды, потом сравнивать их на тех же условиях.

Маршрут работы с одной границей

  1. Найти один внешний payload и записать, какие поля текущий экран действительно читает. Не расширять контракт полями, которые не участвуют в операции.
  2. Оставить transport на JavaScript, добавить @ts-check только в этот файл при необходимости и включить allowJs, чтобы новый .ts-модуль мог импортировать его без массового rename.
  3. Написать normalizer с входом unknown и отрицательными fixture для отсутствующего поля и недопустимого варианта статуса.
  4. Поменять экран так, чтобы он принимал только Member после normalizer. Не передавать response.body через any ради временного прохождения compiler.
  5. Запустить выбранный type-check, потом существующую production-сборку, потом smoke-путь с известным Member. Зафиксировать команды и их scope в ревью.
  6. Выпустить маленький change. При сбое откатить связь normalizer со screen, сохранив transport и прежний pipeline нетронутыми; затем разбирать один фактический сигнал.

Как измерять продвижение без выдуманных процентов

Для этой migration-партии полезна не диаграмма «x процентов TypeScript», а журнал границ. В нём одна строка на поток: источник payload, normalizer, тип модели, fixture, команда проверки и выпускной gate. Полезное покрытие можно вычислить только как метод: число строк, где заполнены все шесть полей, делить на число выбранных потоков. Пока такой журнал не собран, числа нет и писать его в отчёт нельзя.

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

Ограничения fixture и следующий шаг

Фикстура не запускает реальный сервер, не проверяет сборочный pipeline этого репозитория и не доказывает, что response.body в конкретной библиотеке имеет указанную форму. Она нужна, чтобы показать структуру решения и честный список gates. В production-проекте normalizer требует contract-теста с API либо контролируемого тестового ответа; build требует фактического запуска в своей среде.

После такого изменения следующий разумный шаг — соседний поток с тем же источником данных, а не глобальный strict mode. Если два экрана используют один endpoint, можно выделить общую функцию нормализации и добавить fixture для списка. Когда накопится несколько проверенных швов, станет видно, какие правила tsconfig действительно можно распространить. До этого момента успех миграции измеряется сохранённым выпуском и конкретными контрактами, а не длиной списка переименованных файлов.

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

  • TypeScript Handbook: Migrating from JavaScript — описывает постепенный переход: JavaScript остаётся входом компилятора при allowJs, а файлы можно переводить по одному
  • TSConfig Reference: allowJs — разрешает включать JavaScript-файлы вместе с TypeScript и тем самым не требует одномоментного переименования всего дерева
  • TSConfig Reference: checkJs — включает диагностические сообщения для JavaScript-файлов; эквивалентный локальный маркер — комментарий @ts-check
  • TypeScript 3.5 release notes — майский релиз 2019 года: в нём зафиксированы улучшения проверки, incremental-сборки и уточнения поведения generic-параметров