Migrer une base de code volumineuse de Flow vers TypeScript
Comparez les migrations Flow vers TypeScript globales et progressives : codemods, coexistence des vérificateurs, conversion des types et suivi des progrès.
Pour migrer une base de code volumineuse de Flow vers TypeScript, il faut choisir entre deux stratégies avant de toucher au code. La première consiste à exécuter un seul codemod sur l’ensemble du dépôt. Elle convient à du code déjà entièrement typé avec Flow et capable de supporter un court gel. La seconde est une conversion incrémentale, en partant des feuilles, qui maintient les deux vérificateurs de types en service pendant des mois, voire des années.
Chaque mise à jour de Flow prend trop de temps, les libdefs dont vous avez besoin sont absentes ou obsolètes, et les nouvelles recrues demandent sans cesse pourquoi le dépôt n’est pas en TypeScript. Pour autant, suspendre le développement fonctionnel pendant un trimestre reste exclu.
Ce guide compare les deux stratégies, explique comment Flow et TypeScript cohabitent pendant une migration incrémentale, recense les types propres à Flow que les codemods ne savent pas convertir proprement et décrit comment éviter que l’effort ne s’enlise.
Points clés
- Pinterest a converti 3,7 millions de lignes de Flow en TypeScript en huit mois avec une seule exécution de codemod, et a vérifié que la sortie Babel des deux systèmes de types était identique octet pour octet.
- Yelp a migré 1,4 million de lignes de code source paquet par paquet en trois ans et sept mois, faisant passer la couverture de types de 83,15 % sous Flow à 96,44 % en TypeScript.
- TypeScript ne dispose pas de types objet exacts. Il ne rejette les propriétés en trop que lorsqu’un littéral objet est affecté directement à une cible typée.
- Le
opaque typede Flow devient un type « branded » (type marqué) en TypeScript, et$Shape<T>devientPartial<T>, ce que Flow lui-même recommande désormais. - Les migrations incrémentales s’enlisent une fois que les équipes les plus motivées ont terminé. Classer les modules restants selon le nombre d’autres modules qu’ils débloquent, et fixer des échéances datées, permet de maintenir la dynamique.
Pourquoi des équipes migrent encore de Flow vers TypeScript
Les équipes quittent Flow parce que l’écosystème qui l’entoure s’est réduit. Les types TypeScript des paquets tiers sont faciles à trouver, l’outillage présuppose TypeScript, et chaque année passée sur Flow alourdit le coût de la migration à venir. En mai 2021, l’équipe Flow a annoncé qu’elle donnerait la priorité à la base de code interne de Facebook plutôt qu’à la communauté open source. Yelp a cité cette annonce comme facteur décisif, aux côtés du catalogue bien plus fourni de DefinitelyTyped par rapport à flow-typed et du nombre croissant de paquets livrant leurs propres déclarations TypeScript.
Le coût du statu quo se manifeste aussi dans l’outillage. Après avoir quitté Flow, Yelp a pu adopter swc et @typescript-eslint. Certaines bases de code Flow utilisent également PropTypes. Il s’agit d’une couche distincte : PropTypes valide les props à l’exécution, alors que Flow et TypeScript vérifient les types à la compilation. La décision concernant PropTypes doit donc être prise indépendamment de la migration du vérificateur.
Codemod « big bang » ou migration incrémentale : quelle stratégie choisir ?
Une migration « big bang » par codemod convient à une base de code déjà entièrement typée avec Flow et capable de tolérer un court gel. Une migration incrémentale convient à un monorepo où le développement fonctionnel ne peut pas s’arrêter, au prix de faire tourner deux vérificateurs de types en parallèle pendant des mois, voire des années.
| Codemod « big bang » | Incrémentale | |
|---|---|---|
| Prérequis | Couverture Flow complète | Paquets aux dépendances clairement délimitées |
| Gel | Court, au moment de la bascule | Aucun |
| Durée calendaire | Des mois de préparation | Des années |
| Pic d’effort | Script d’automatisation et validation | Chaîne d’outils d’interopérabilité et accompagnement des équipes |
| Concentration du risque | Jour de la bascule | La longue traîne |
| Échec typique | Build ou chemins cassés lors du merge | Enlisement à mi-parcours |
| Référence | Pinterest : 3,7 M de lignes, huit mois | Yelp : 1,4 M de lignes de code source, 3 ans et 7 mois |
Pinterest a d’abord atteint 100 % de couverture Flow, ce qui a rendu possible un commit unique. Son équipe a ensuite exécuté le flow-to-typescript-codemod de Stripe, qui fonctionne en deux passes : une commande convert basée sur Babel réécrit la syntaxe Flow en TypeScript, puis une commande fix construite sur ts-morph corrige, supprime ou signale les erreurs TypeScript restantes. Pinterest reconstruisait chaque jour la branche TypeScript à partir de master, plus de 60 fois avant la bascule, et regroupait toutes les corrections manuelles dans un unique commit appliqué par cherry-pick. Factorial, dans un article de février 2022, a décrit une approche hybride. L’équipe a d’abord converti un périmètre découplé pour démontrer que Flow et TypeScript pouvaient fonctionner ensemble en production, puis a converti le reste d’un seul coup avec flow-to-ts de Khan Academy.
Les deux stratégies de migration de Flow vers TypeScript peuvent s’appuyer sur la même vérification d’équivalence. Les annotations de type sont supprimées avant l’exécution du code : une conversion correcte produit donc le même JavaScript. Pinterest a comparé la sortie Babel des deux sources pour le prouver. Ce qui casse réellement en production provient généralement des fichiers renommés, des globs mis à jour et de la configuration du build. Pinterest a trouvé près de 100 références .js codées en dur. L’examen des sessions de production après chaque bascule révèle les pages qui ne s’affichent plus et les erreurs de chargement de chunks que les vérifications statiques ne détectent pas.
Faire cohabiter Flow et TypeScript
Dans une migration incrémentale, les modules TypeScript convertis ne doivent jamais importer de code Flow non converti. La conversion commence donc par les feuilles du graphe de dépendances et remonte progressivement. Yelp a utilisé flowgen pour transformer la sortie .d.ts de chaque paquet converti en types compatibles Flow, destinés au code Flow qui l’importait encore. L’équipe n’a trouvé aucun outil capable de faire l’inverse, c’est-à-dire de générer des déclarations TypeScript à partir de Flow, ce qui l’a obligée à commencer par les paquets internes les plus profonds. Flow lit un fichier .js.flow à la place du module .js situé à côté : c’est l’emplacement naturel pour la sortie de flowgen. Le flow-api-translator de Meta peut désormais générer des définitions TypeScript à partir de sources Flow, mais ses auteurs le destinent à la publication de bibliothèques qui restent en Flow, et non à l’abandon de Flow.
Tenez les fichiers Flow non convertis à l’écart du programme TypeScript et laissez Flow seul les vérifier. allowJs et checkJs sont conçus pour du JavaScript pur, pas pour des fichiers annotés avec Flow. Migrez les règles ESLint au fur et à mesure de la conversion de chaque module (Pinterest a fait correspondre flowtype/no-weak-types à @typescript-eslint/no-explicit-any), en suivant une configuration typescript-eslint. Configurez les éditeurs pour qu’ils utilisent la version de TypeScript de l’espace de travail.
Empêchez la surface Flow de s’étendre grâce à une vérification en 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);
}
Le script no-new-flow.js liste les fichiers ajoutés par la branche par rapport à la référence de base et fait échouer le job si l’un d’eux contient @flow. Pour renommer, utilisez git mv src/foo.js src/foo.ts et committez les renommages séparément des modifications de contenu, afin que git log --follow conserve l’historique.
Les types Flow qui ne se traduisent pas directement
Quatre constructions Flow n’ont pas d’équivalent direct en TypeScript : les objets exacts, les marqueurs de variance, les types opaques et $Shape.
Types objet exacts
TypeScript ne dispose pas de types objet exacts. Un objet exact Flow devient un type objet ordinaire, et TypeScript ne rejette les propriétés en trop que lorsqu’un littéral objet est affecté directement à ce type. Les types objet Flow sont exacts par défaut depuis la version 0.202. {| |} est l’ancienne syntaxe explicite, et ... signale un objet inexact.
// 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
Variance
Le marqueur de propriété + de Flow correspond au readonly de TypeScript. Le marqueur - (écriture seule) n’a pas d’équivalent en TypeScript. TypeScript propose en revanche les modificateurs in/out sur les paramètres de type génériques.
type Config = { readonly endpoint: string }; // Flow: { +endpoint: string }
type Sink = { value: number }; // Flow: { -value: number }; intent must be documented
Types opaques
Le opaque type de Flow n’a pas de mot-clé équivalent en TypeScript. Le remplacement habituel est un type « branded » : une intersection du type de base avec une propriété marqueur unique que seule une fonction constructeur désignée peut produire. Un type opaque Flow est transparent à l’intérieur du fichier qui le définit. Une marque, elle, est appliquée partout : le constructeur nécessite donc un cast.
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 a déprécié $Shape, jugé non sûr, et recommande depuis Flow 0.201 d’utiliser Partial<T>, qui correspond directement au Partial<T> de TypeScript. Vérifiez tout site d’appel qui dépendait d’autres comportements de $Shape.
function update(patch: Partial<User>) { /* ... */ } // Flow: $Shape<User>
Les correspondances de types plus simples entre Flow et TypeScript sont directes :
| Flow | TypeScript |
|---|---|
?T | T | null | undefined |
mixed | unknown |
$ReadOnly<T> | Readonly<T> |
$Keys<T> | keyof T |
$ReadOnlyArray<T> | ReadonlyArray<T> |
React.Node | React.ReactNode |
Remplacer les libdefs flow-typed
Une fois un paquet converti, supprimez ses libdefs flow-typed. Utilisez les types fournis par la dépendance via le champ types de son package.json lorsqu’ils existent, et installez les @types/* depuis DefinitelyTyped dans le cas contraire. Les libdefs écrites à la main pour des paquets internes ou non maintenus doivent être réécrites sous forme de fichiers .d.ts. Commencez-les tôt, car elles bloquent tous leurs consommateurs.
Mesurer la progression pour que la migration ne s’enlise pas
Les migrations incrémentales s’enlisent une fois que les équipes enthousiastes ont terminé. Ce qui a permis de faire avancer les quelque 200 derniers paquets chez Yelp, c’est le classement des modules Flow restants selon le nombre d’autres modules qu’ils débloquent, et la publication de listes de conversion datées. Suivez trois indicateurs chaque semaine :
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
Mesurez également la couverture de types avant et après. Attribuez à chaque module bloquant un score correspondant au nombre de modules Flow qui l’attendent, et présentez le haut de cette liste aux équipes propriétaires. Yelp a fixé des échéances trimestrielles menant à une date de fin de vie. Cette date a glissé d’un trimestre, mais la pression a porté ses fruits. Prévoyez une solution de repli par suppression automatique pour les derniers retardataires : environ 2 000 des quelque 6 000 directives @ts-expect-error de Yelp sont issues de cette dernière étape.
Un ordre de grandeur réaliste : des mois, pas un sprint
Une migration de Flow vers TypeScript à grande échelle prend au minimum plusieurs mois. L’approche par codemod de Pinterest a duré huit mois, et l’approche incrémentale de Yelp plus de trois ans et demi. Activez progressivement les options strict une fois la conversion terminée. Avec le compilateur basé sur JavaScript (TypeScript 6.0 et versions antérieures), Pinterest a relevé la limite du tas de Node pour éviter que tsc ne manque de mémoire. Son article ne précise pas la valeur utilisée : considérez donc le chiffre ci-dessous comme un exemple :
NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit
Ajustez cette valeur en fonction de votre machine. Même ainsi, Pinterest a fait état de vérifications de types complètes de 3 à 4 minutes. TypeScript 6.0 est la dernière version basée sur JavaScript, et c’est avec le compilateur natif de TypeScript 7 que la vitesse de vérification des types sur les très grands dépôts change réellement. Prochaine étape : comptez vos fichiers @flow, vérifiez si votre couverture atteint 100 %, et choisissez votre stratégie en fonction de ce chiffre.
FAQ
Les commentaires $FlowFixMe doivent-ils devenir @ts-expect-error ou @ts-ignore en TypeScript ?
Utilisez @ts-expect-error. Les deux directives suppriment l'erreur de la ligne suivante, mais @ts-expect-error, introduit dans TypeScript 3.9, signale une erreur « Unused @ts-expect-error directive » dès que cette ligne cesse d'échouer. Les suppressions obsolètes apparaissent ainsi à mesure que les types s'améliorent, et le nombre de suppressions ne peut que diminuer. @ts-ignore reste silencieux une fois le problème sous-jacent corrigé, et peut donc masquer de nouvelles erreurs sur la même ligne.
Par quoi remplacer $PropertyType et $ElementType de Flow en TypeScript ?
Par des types d'accès indexé, dont la syntaxe est identique en Flow et en TypeScript : $PropertyType<T, 'k'> devient T['k'] et $ElementType<T, K> devient T[K]. Flow a déprécié ces deux utilitaires dans la version 0.155 et les a supprimés dans la version 0.266. Exécuter le correctif automatique use-indexed-access-type d'eslint-plugin-fb-flow avant la conversion permet aux codemods de reprendre ces types tels quels.
Remplacer @babel/preset-flow par @babel/preset-typescript permet-il de vérifier les types du code ?
Non. Babel supprime les types TypeScript sans les vérifier : la CI a donc toujours besoin d'une étape tsc --noEmit distincte. Dans Babel 8, l'option onlyRemoveTypeImports de preset-typescript vaut true par défaut, si bien que Babel ne supprime que les imports marqués avec import type. Les imports classiques de liaisons utilisées uniquement comme types restent dans la sortie et peuvent casser la résolution des modules. Marquez ces imports avec import type, ou définissez onlyRemoveTypeImports à false pour retrouver le comportement de Babel 7.
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