12k
All articles

Миграция крупной кодовой базы с Flow на TypeScript

Сравните массовый и постепенный переход с Flow на TypeScript: кодмоды, совместная работа проверок типов, преобразование типов и контроль прогресса.

OpenReplay Team
OpenReplay Team
Миграция крупной кодовой базы с Flow на TypeScript

Прежде чем начинать миграцию крупной кодовой базы с Flow на TypeScript, нужно выбрать одну из двух стратегий. Первая — однократный прогон codemod по всему репозиторию. Она подходит для кода, который уже полностью типизирован Flow и может пережить короткую заморозку. Вторая — инкрементальная конвертация от листьев графа зависимостей вверх, при которой оба тайпчекера работают параллельно месяцы или даже годы.

Каждое обновление Flow затягивается, нужные libdefs отсутствуют или устарели, а новые сотрудники продолжают спрашивать, почему репозиторий до сих пор не на TypeScript. При этом остановить разработку фич на квартал по-прежнему нельзя.

В этом руководстве мы сравним обе стратегии, разберём, как Flow и TypeScript сосуществуют при инкрементальной миграции, покажем, чем заменить специфичные для Flow типы, которые codemod не может корректно сконвертировать, и расскажем, как не дать миграции заглохнуть.

Ключевые выводы

  • Pinterest перевела 3,7 млн строк кода с Flow на TypeScript за восемь месяцев одним прогоном codemod и убедилась, что вывод Babel для обеих систем типов совпадает побайтово.
  • Yelp переносила 1,4 млн строк исходного кода пакет за пакетом в течение трёх лет и семи месяцев и подняла покрытие типами с 83,15% на Flow до 96,44% на TypeScript.
  • В TypeScript нет точных (exact) объектных типов. Лишние свойства отклоняются только при прямом присваивании объектного литерала типизированной цели.
  • opaque type из Flow превращается в TypeScript в брендированный тип, а $Shape<T> — в Partial<T>, который теперь рекомендует и сам Flow.
  • Инкрементальные миграции буксуют, когда заинтересованные команды заканчивают свою часть. Ранжирование оставшихся модулей по количеству модулей, которые они разблокируют, и жёсткие сроки помогают сохранить темп.

Почему команды до сих пор мигрируют с Flow на TypeScript

Команды уходят с Flow, потому что его экосистема сократилась. Типы TypeScript для сторонних пакетов найти легко, инструменты рассчитаны на TypeScript, а каждый год на Flow увеличивает стоимость будущей миграции. В мае 2021 года команда Flow объявила, что будет отдавать приоритет внутренней кодовой базе Facebook, а не open-source-сообществу. Yelp назвала это заявление одним из решающих факторов — наряду с тем, что каталог DefinitelyTyped намного больше, чем flow-typed, и всё больше пакетов поставляются с собственными декларациями TypeScript.

Цена отказа от миграции проявляется и в инструментах. Уйдя с Flow, Yelp смогла внедрить swc и @typescript-eslint. В некоторых кодовых базах на Flow также используется PropTypes. Это отдельный уровень: PropTypes проверяют пропсы во время выполнения, а Flow и TypeScript проверяют типы на этапе сборки, поэтому решение о PropTypes принимайте отдельно от миграции тайпчекера.

Big-bang через codemod или инкрементальная миграция: что выбрать?

Миграция «одним махом» (big-bang) через codemod подходит кодовой базе, которая уже полностью типизирована Flow и выдержит короткую заморозку. Инкрементальная миграция подходит монорепозиторию, где разработку фич остановить нельзя, а платить за это приходится параллельной работой двух тайпчекеров в течение месяцев или лет.

Big-bang через codemodИнкрементальная
Предварительное условиеПолное покрытие FlowПакеты с чёткими границами зависимостей
ЗаморозкаКороткая, в момент переключенияНет
Календарные срокиМесяцы подготовкиГоды
Пик усилийСкрипт автоматизации и валидацияИнструменты для взаимодействия и работа с командами
Где концентрируется рискДень переключения«Длинный хвост»
Типичный сбойПоломка сборки или путей при слиянииЗастревание на частичном завершении
ПримерPinterest: 3,7 млн строк, восемь месяцевYelp: 1,4 млн SLoC, 3 года 7 месяцев

Pinterest сначала добилась 100% покрытия Flow, что сделало возможным переход одним коммитом. Затем команда запустила flow-to-typescript-codemod от Stripe, который работает в два прохода: команда convert на базе Babel переписывает синтаксис Flow в TypeScript, а команда fix на основе ts-morph исправляет, подавляет или выводит в отчёт оставшиеся ошибки TypeScript. Pinterest ежедневно пересобирала ветку с TypeScript из master — к моменту переключения более 60 раз — и собрала все ручные исправления в один коммит, который переносился через cherry-pick. Factorial в посте от февраля 2022 года описала гибридный путь: сначала конвертировали одну изолированную область, чтобы доказать, что Flow и TypeScript могут работать вместе в продакшене, а затем перевели всё остальное за раз с помощью flow-to-ts от Khan Academy.

Обе стратегии могут использовать одну и ту же проверку эквивалентности. Аннотации типов удаляются до выполнения кода, поэтому корректная конвертация даёт тот же JavaScript. Pinterest сравнила вывод Babel для обоих исходников, чтобы это доказать. То, что всё же ломает продакшен, обычно связано с переименованными файлами, изменёнными glob-шаблонами и конфигурацией сборки: Pinterest обнаружила почти 100 захардкоженных ссылок на .js. Анализ продакшен-сессий после каждого переключения выявляет страницы, которые перестали отрисовываться, и ошибки загрузки чанков, которые статические проверки пропускают.

Параллельная работа Flow и TypeScript

При инкрементальной миграции сконвертированные модули TypeScript никогда не должны импортировать неконвертированный код на Flow. Поэтому конвертация начинается с листьев графа зависимостей и движется вверх. Yelp использовала flowgen, чтобы превращать выходные .d.ts каждого сконвертированного пакета в совместимые с Flow типы для кода на Flow, который всё ещё его импортировал. Инструмента для обратного направления — генерации деклараций TypeScript из Flow — команда не нашла, поэтому и пришлось начинать с самых глубоких внутренних пакетов. Flow читает файл .js.flow вместо соседнего модуля .js, так что это естественное место для вывода flowgen. Сейчас flow-api-translator от Meta умеет генерировать определения TypeScript из исходников на Flow, но авторы предназначают его для публикации библиотек, которые остаются на Flow, а не для ухода с него.

Не включайте неконвертированные файлы Flow в TypeScript-программу — пусть их проверяет только Flow. allowJs и checkJs предназначены для обычного JavaScript, а не для файлов с аннотациями Flow. Переносите правила ESLint по мере конвертации каждого модуля (Pinterest сопоставила flowtype/no-weak-types с @typescript-eslint/no-explicit-any), опираясь на настройку typescript-eslint. Настройте редакторы на версию TypeScript из рабочего пространства.

Остановите рост кода на Flow с помощью проверки в CI:

// scripts/no-new-flow.js
const { execSync } = require("node:child_process");
const { readFileSync } = require("node:fs");

const base = process.env.BASE_REF || "origin/main";
const added = execSync(`git diff --name-only --diff-filter=A ${base}...HEAD`, { encoding: "utf8" })
  .split("\n")
  .filter((f) => /\.(js|jsx)$/.test(f));

const offenders = added.filter((f) => /@flow\b/.test(readFileSync(f, "utf8")));

if (offenders.length) {
  console.error("New Flow files are not allowed. Write these in TypeScript:\n" + offenders.join("\n"));
  process.exit(1);
}

Скрипт no-new-flow.js получает список файлов, добавленных в ветке относительно базовой ссылки, и завершает задачу с ошибкой, если какой-либо из них содержит @flow. При переименовании используйте git mv src/foo.js src/foo.ts и коммитьте переименования отдельно от изменений содержимого, чтобы git log --follow сохранял историю.

Типы Flow, которые не переводятся напрямую

У четырёх конструкций Flow нет прямого эквивалента в TypeScript: точные объекты, знаки вариантности, opaque-типы и $Shape.

Точные объектные типы

В TypeScript нет точных объектных типов. Точный объект Flow превращается в обычный объектный тип, и TypeScript отклоняет лишние свойства только при прямом присваивании объектного литерала этому типу. Объектные типы во Flow точные по умолчанию начиная с версии 0.202. {| |} — это более старый явный синтаксис, а ... обозначает неточный объект.

// Flow
type User = { id: string, name: string };        // exact by default
type LooseUser = { id: string, name: string, ... };
// TypeScript
type User = { id: string; name: string };
const u: User = { id: "1", name: "Ada", role: "admin" }; // error: excess property
const extra = { id: "1", name: "Ada", role: "admin" };
const v: User = extra; // allowed: not a fresh literal

Вариантность

Знак свойства + во Flow соответствует readonly в TypeScript. У знака - (только запись) эквивалента в TypeScript нет. При этом TypeScript поддерживает модификаторы in/out для параметров обобщённых типов.

type Config = { readonly endpoint: string }; // Flow: { +endpoint: string }
type Sink = { value: number };               // Flow: { -value: number }; intent must be documented

Opaque-типы

У opaque type из Flow нет эквивалентного ключевого слова в TypeScript. Обычная замена — брендированный тип: пересечение базового типа с уникальным свойством-маркером, которое может создать только специально выделенная функция-конструктор. Opaque-тип Flow прозрачен внутри файла, где он определён. Бренд же соблюдается везде, поэтому в конструкторе нужно приведение типа.

declare const userIdBrand: unique symbol;
export type UserId = string & { readonly [userIdBrand]: true };
export function toUserId(s: string): UserId {
  return s as UserId;
}
const bad: UserId = "u_1"; // error

$Shape

Flow объявил $Shape устаревшим как небезопасный и начиная с версии 0.201 рекомендует Partial<T>, который напрямую соответствует Partial<T> в TypeScript. Проверьте все места вызова, которые полагались на другие особенности поведения $Shape.

function update(patch: Partial<User>) { /* ... */ } // Flow: $Shape<User>

Более простые соответствия типов Flow и TypeScript прямые:

FlowTypeScript
?TT | null | undefined
mixedunknown
$ReadOnly<T>Readonly<T>
$Keys<T>keyof T
$ReadOnlyArray<T>ReadonlyArray<T>
React.NodeReact.ReactNode

Замена libdefs из flow-typed

После конвертации пакета удалите его libdefs из flow-typed. Используйте типы, которые зависимость поставляет через поле types в package.json, если они есть, а если нет — устанавливайте @types/* из DefinitelyTyped. Написанные вручную libdefs для внутренних или заброшенных пакетов придётся переписать в виде файлов .d.ts. Начинайте с них пораньше, потому что они блокируют всех потребителей.

Как измерять прогресс, чтобы миграция не заглохла

Инкрементальные миграции буксуют, когда команды-энтузиасты заканчивают свою часть. Последние примерно 200 пакетов в Yelp сдвинулись с места благодаря ранжированию оставшихся модулей Flow по количеству модулей, которые они разблокируют, и публикации списков на конвертацию с конкретными сроками. Еженедельно отслеживайте три показателя:

grep -rl --include='*.js' --include='*.jsx' '@flow' src | wc -l                    # Flow files left
find src \( -name '*.ts' -o -name '*.tsx' \) ! -name '*.d.ts' | wc -l               # TS files
grep -ro '@ts-expect-error' src | wc -l                                            # suppressions

Также замеряйте покрытие типами до и после. Оценивайте каждый блокирующий модуль по количеству ожидающих его модулей Flow и передавайте верхушку этого списка командам-владельцам. Yelp устанавливала ежеквартальные дедлайны, ведущие к дате окончания поддержки Flow. Дата сдвинулась на один квартал, но давление сработало. Предусмотрите запасной вариант с автоматическим подавлением ошибок для последних отстающих модулей: около 2000 из примерно 6000 директив @ts-expect-error в Yelp появились именно на этом заключительном этапе.

Реалистичный масштаб: месяцы, а не спринт

Крупная миграция с Flow на TypeScript занимает как минимум несколько месяцев. Путь Pinterest через codemod занял восемь месяцев, инкрементальный путь Yelp — более трёх с половиной лет. Включайте флаги strict постепенно, после завершения конвертации. Для компилятора на JavaScript (TypeScript 6.0 и более ранние версии) Pinterest увеличила лимит кучи Node, чтобы tsc не исчерпывал память. В посте компании не указано использованное значение, поэтому число ниже — лишь пример:

NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit

Подбирайте это значение под свою машину. Даже так Pinterest сообщала о полной проверке типов за 3–4 минуты. TypeScript 6.0 стал последним релизом на JavaScript, а со нативным компилятором TypeScript 7 скорость проверки типов на очень больших репозиториях меняется. Ваш следующий шаг — подсчитать файлы с @flow, проверить, достигнуто ли 100% покрытие, и выбрать стратегию на основе этих данных.

Часто задаваемые вопросы

Во что превращать комментарии $FlowFixMe в TypeScript — в @ts-expect-error или в @ts-ignore?

Используйте @ts-expect-error. Обе директивы подавляют ошибку на следующей строке, но @ts-expect-error, появившаяся в TypeScript 3.9, выдаёт ошибку 'Unused @ts-expect-error directive', как только строка перестаёт быть ошибочной. Благодаря этому устаревшие подавления всплывают по мере улучшения типов, и их количество может только уменьшаться. @ts-ignore молчит и после устранения исходной проблемы, поэтому может скрыть новые ошибки в той же строке.

Чем в TypeScript заменить $PropertyType и $ElementType из Flow?

Типами индексного доступа (indexed access types), синтаксис которых во Flow и TypeScript одинаков: $PropertyType<T, 'k'> становится T['k'], а $ElementType<T, K> — T[K]. Flow объявил обе утилиты устаревшими в версии 0.155 и удалил их в 0.266. Если перед конвертацией запустить автоисправление use-indexed-access-type из eslint-plugin-fb-flow, codemod перенесёт эти типы без изменений.

Выполняет ли замена @babel/preset-flow на @babel/preset-typescript проверку типов?

Нет. Babel удаляет типы TypeScript, не проверяя их, поэтому в CI по-прежнему нужен отдельный шаг tsc --noEmit. В Babel 8 опция onlyRemoveTypeImports в preset-typescript по умолчанию равна true, поэтому Babel удаляет только импорты, помеченные import type. Обычные импорты привязок, используемых только как типы, остаются в выходном коде и могут сломать разрешение модулей. Пометьте такие импорты как import type или установите onlyRemoveTypeImports в false, чтобы вернуть поведение Babel 7.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.