12k
All articles

Migration einer großen Codebasis von Flow zu TypeScript

Vergleichen Sie Big-Bang- und schrittweise Migrationen von Flow zu TypeScript – mit Tipps zu Codemods, parallelen Prüfern, Typumstellungen und Fortschritt.

OpenReplay Team
OpenReplay Team
Migration einer großen Codebasis von Flow zu TypeScript

Bevor Sie eine große Codebasis von Flow zu TypeScript migrieren und die erste Zeile Code anfassen, müssen Sie sich zwischen zwei Strategien entscheiden. Die erste ist ein einziger Codemod-Durchlauf über das gesamte Repository. Sie eignet sich für Code, der bereits vollständig mit Flow typisiert ist und einen kurzen Freeze verkraftet. Die zweite ist eine inkrementelle Umstellung von den Blättern des Abhängigkeitsgraphen aus, bei der beide Typechecker über Monate oder Jahre parallel laufen.

Jedes Flow-Upgrade dauert zu lange, die benötigten Libdefs fehlen oder sind veraltet, und neue Kolleginnen und Kollegen fragen immer wieder, warum das Repo nicht auf TypeScript läuft. Die Feature-Entwicklung für ein Quartal anzuhalten, ist trotzdem keine Option.

Dieses Playbook vergleicht die beiden Strategien und beschreibt, wie Flow und TypeScript während einer inkrementellen Migration koexistieren. Es ordnet die reinen Flow-Typen zu, die Codemods nicht sauber konvertieren können, und zeigt, wie Sie verhindern, dass das Vorhaben ins Stocken gerät.

Das Wichtigste in Kürze

  • Pinterest hat 3,7 Millionen Zeilen Flow-Code in acht Monaten mit einem einzigen Codemod-Durchlauf zu TypeScript konvertiert. Dabei wurde bestätigt, dass die Babel-Ausgabe beider Typsysteme Byte für Byte identisch war.
  • Yelp hat 1,4 Millionen Source Lines of Code über drei Jahre und sieben Monate Paket für Paket migriert. Die Typabdeckung stieg dabei von 83,15 % unter Flow auf 96,44 % in TypeScript.
  • TypeScript kennt keine Exact Object Types. Überzählige Properties werden nur abgelehnt, wenn ein Objektliteral direkt einem typisierten Ziel zugewiesen wird.
  • Aus Flows opaque type wird in TypeScript ein Branded Type, und aus $Shape<T> wird Partial<T>, was Flow inzwischen selbst empfiehlt.
  • Inkrementelle Migrationen geraten ins Stocken, sobald die engagierten Teams fertig sind. Halten lässt sich das Tempo, indem die verbleibenden Module danach priorisiert werden, wie viele andere sie freischalten, und indem verbindliche Termine gesetzt werden.

Warum Teams immer noch von Flow zu TypeScript migrieren

Teams verlassen Flow, weil das Ökosystem rund um Flow geschrumpft ist. TypeScript-Typen für Drittanbieterpakete sind leicht zu finden, das Tooling setzt TypeScript voraus, und jedes weitere Jahr mit Flow erhöht die Kosten der späteren Migration. Im Mai 2021 kündigte das Flow-Team an, die interne Codebasis von Facebook zu priorisieren, nicht die Open-Source-Community. Yelp nannte diese Ankündigung als ausschlaggebenden Faktor. Hinzu kamen der deutlich größere Katalog von DefinitelyTyped im Vergleich zu flow-typed und die wachsende Zahl von Paketen, die eigene TypeScript-Deklarationen mitliefern.

Die Kosten des Verbleibens zeigen sich auch beim Tooling. Nach dem Abschied von Flow konnte Yelp swc und @typescript-eslint einführen. Manche Flow-Codebasen nutzen zusätzlich PropTypes. Dabei handelt es sich um eine eigene Ebene: PropTypes validieren Props zur Laufzeit, Flow und TypeScript prüfen Typen dagegen zur Build-Zeit. Entscheiden Sie daher über PropTypes unabhängig von der Migration des Typecheckers.

Big-Bang-Codemod oder inkrementell: Welche Strategie passt?

Eine Big-Bang-Migration per Codemod eignet sich für eine Codebasis, die bereits vollständig mit Flow typisiert ist und einen kurzen Freeze verkraftet. Eine inkrementelle Migration eignet sich für ein Monorepo, in dem die Feature-Entwicklung nicht pausieren kann. Der Preis dafür ist, dass zwei Typechecker über Monate oder Jahre parallel laufen.

Big-Bang-CodemodInkrementell
VoraussetzungVollständige Flow-AbdeckungPakete mit klaren Abhängigkeitsgrenzen
FreezeKurz, beim CutoverKeiner
ZeitrahmenMonate an VorbereitungJahre
Höchster AufwandAutomatisierungsskript und ValidierungInterop-Toolchain und Abstimmung mit den Teams
Risiko konzentriert sich aufDen Tag des CutoversDie lange Restphase
Typisches ScheiternBuild- oder Pfadfehler beim MergeStillstand bei teilweiser Fertigstellung
ReferenzPinterest: 3,7 Mio. Zeilen, acht MonateYelp: 1,4 Mio. SLoC, 3 Jahre 7 Monate

Pinterest erreichte zunächst 100 % Flow-Abdeckung, was einen einzigen Commit überhaupt erst möglich machte. Anschließend setzte das Team Stripes flow-to-typescript-codemod ein, das in zwei Durchläufen arbeitet: Ein Babel-basierter convert-Befehl schreibt Flow-Syntax in TypeScript um, danach repariert, unterdrückt oder meldet ein auf ts-morph aufbauender fix-Befehl die verbleibenden TypeScript-Fehler. Pinterest baute den TypeScript-Branch jeden Tag neu aus master auf, bis zum Cutover mehr als 60 Mal, und bündelte alle manuellen Korrekturen in einem einzigen Commit, der per Cherry-Pick übernommen wurde. Factorial beschrieb in einem Beitrag vom Februar 2022 einen hybriden Ansatz. Das Unternehmen konvertierte zunächst einen entkoppelten Bereich, um zu zeigen, dass Flow und TypeScript in Produktion nebeneinander laufen können. Den Rest konvertierte es anschließend in einem Durchgang mit flow-to-ts von Khan Academy.

Beide Strategien für die Migration von Flow zu TypeScript können dieselbe Äquivalenzprüfung nutzen. Typannotationen werden entfernt, bevor der Code ausgeführt wird, daher erzeugt eine korrekte Konvertierung dasselbe JavaScript. Pinterest verglich die Babel-Ausgabe beider Quellen per Diff, um das nachzuweisen. Was in Produktion dennoch bricht, geht meist auf umbenannte Dateien, angepasste Globs und die Build-Konfiguration zurück. Pinterest stieß auf fast 100 hartcodierte .js-Referenzen. Wer nach jedem Cutover Produktions-Sessions auswertet, entdeckt Seiten, die nicht mehr rendern, und Chunk-Load-Fehler, die statische Prüfungen übersehen.

Flow und TypeScript parallel betreiben

Bei einer inkrementellen Migration dürfen konvertierte TypeScript-Module niemals nicht konvertierten Flow-Code importieren. Die Konvertierung beginnt deshalb an den Blättern des Abhängigkeitsgraphen und arbeitet sich nach oben vor. Yelp nutzte flowgen, um die .d.ts-Ausgabe jedes konvertierten Pakets in Flow-kompatible Typen für den Flow-Code umzuwandeln, der das Paket noch importierte. Ein Tool für die umgekehrte Richtung, das also TypeScript-Deklarationen aus Flow erzeugt, fand Yelp nicht. Deshalb musste das Unternehmen bei den am tiefsten liegenden internen Paketen beginnen. Flow liest eine .js.flow-Datei anstelle des daneben liegenden .js-Moduls, das ist also ein naheliegender Ablageort für die Ausgabe von flowgen. Metas flow-api-translator kann inzwischen TypeScript-Definitionen aus Flow-Quellcode erzeugen. Die Autoren sehen das Tool aber für die Veröffentlichung von Bibliotheken vor, die in Flow bleiben, nicht für den Umstieg weg von Flow.

Halten Sie nicht konvertierte Flow-Dateien aus dem TypeScript-Programm heraus und lassen Sie sie ausschließlich von Flow prüfen. allowJs und checkJs sind für reines JavaScript gedacht, nicht für Dateien mit Flow-Annotationen. Übertragen Sie die ESLint-Regeln mit jedem konvertierten Modul (Pinterest bildete flowtype/no-weak-types auf @typescript-eslint/no-explicit-any ab) und orientieren Sie sich dabei an einem typescript-eslint-Setup. Konfigurieren Sie die Editoren so, dass sie die TypeScript-Version des Workspace verwenden.

Verhindern Sie mit einer CI-Prüfung, dass der Flow-Anteil weiter wächst:

// 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);
}

Das Skript no-new-flow.js ermittelt die Dateien, die der Branch gegenüber der Basisreferenz hinzufügt, und lässt den Job fehlschlagen, sobald eine davon @flow enthält. Verwenden Sie zum Umbenennen git mv src/foo.js src/foo.ts und committen Sie Umbenennungen getrennt von inhaltlichen Änderungen, damit git log --follow die Historie erhält.

Flow-Typen ohne direkte Entsprechung

Für vier Flow-Konstrukte gibt es keine Eins-zu-eins-Entsprechung in TypeScript: Exact Objects, Varianz-Sigils, Opaque Types und $Shape.

Exact Object Types

TypeScript kennt keine Exact Object Types. Ein exaktes Flow-Objekt wird zu einem gewöhnlichen Objekttyp, und TypeScript lehnt überzählige Properties nur ab, wenn ein Objektliteral direkt diesem Typ zugewiesen wird. Objekttypen in Flow sind seit Version 0.202 standardmäßig exakt. {| |} ist die ältere explizite Syntax, ... kennzeichnet ein inexaktes Objekt.

// 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

Varianz

Flows Property-Sigil + entspricht readonly in TypeScript. Für das Sigil - (write-only) gibt es in TypeScript kein Gegenstück. TypeScript bietet allerdings die Modifier in/out für generische Typparameter.

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

Opaque Types

Für Flows opaque type gibt es in TypeScript kein entsprechendes Schlüsselwort. Der übliche Ersatz ist ein Branded Type: eine Intersection aus dem Basistyp und einer eindeutigen Marker-Property, die nur eine dafür vorgesehene Konstruktorfunktion erzeugen kann. Ein Opaque Type in Flow ist innerhalb der Datei, in der er definiert ist, transparent. Ein Brand wird dagegen überall erzwungen, deshalb benötigt der Konstruktor einen 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 hat $Shape als unsicher eingestuft und als veraltet markiert und empfiehlt seit Flow 0.201 Partial<T>, das sich direkt auf TypeScripts Partial<T> abbilden lässt. Prüfen Sie alle Aufrufstellen, die von anderen Eigenschaften von $Shape abhängig waren.

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

Die einfacheren Zuordnungen von Flow- zu TypeScript-Typen sind direkt:

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

flow-typed-Libdefs ersetzen

Sobald ein Paket konvertiert ist, löschen Sie dessen flow-typed-Libdefs. Verwenden Sie die Typen, die eine Abhängigkeit über das Feld types in ihrer package.json mitliefert, sofern vorhanden. Andernfalls installieren Sie @types/* aus DefinitelyTyped. Handgeschriebene Libdefs für interne oder nicht mehr gepflegte Pakete müssen als .d.ts-Dateien neu geschrieben werden. Beginnen Sie damit frühzeitig, denn sie blockieren jedes Paket, das sie nutzt.

Fortschritt messen, damit die Migration nicht ins Stocken gerät

Inkrementelle Migrationen geraten ins Stocken, sobald die begeisterten Teams fertig sind. Bei Yelp brachten zwei Maßnahmen die letzten rund 200 Pakete voran: Die verbleibenden Flow-Module wurden danach priorisiert, wie viele andere sie freischalten, und es wurden Konvertierungslisten mit festen Terminen veröffentlicht. Erfassen Sie jede Woche drei Kennzahlen:

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

Messen Sie außerdem die Typabdeckung vorher und nachher. Bewerten Sie jedes blockierende Modul danach, wie viele Flow-Module darauf warten, und tragen Sie die Spitzenreiter dieser Liste an die verantwortlichen Teams heran. Yelp setzte quartalsweise Fristen bis zu einem End-of-Life-Datum. Dieses Datum verschob sich um ein Quartal, der Druck zeigte aber Wirkung. Planen Sie für die letzten Nachzügler eine automatische Unterdrückung als Fallback ein: Etwa 2.000 der rund 6.000 @ts-expect-error-Direktiven bei Yelp stammten aus diesem letzten Schritt.

Realistischer Umfang: Monate, kein Sprint

Eine große Migration von Flow zu TypeScript dauert mindestens Monate. Pinterest benötigte mit dem Codemod-Ansatz acht Monate, Yelp mit dem inkrementellen Ansatz über dreieinhalb Jahre. Aktivieren Sie die strict-Flags nach Abschluss der Konvertierung schrittweise. Für den JavaScript-basierten Compiler (TypeScript 6.0 und älter) erhöhte Pinterest das Heap-Limit von Node, damit tsc nicht mehr der Speicher ausging. Der Beitrag nennt den verwendeten Wert nicht, betrachten Sie die folgende Zahl daher als Beispiel:

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

Passen Sie diesen Wert an Ihre Hardware an. Trotzdem meldete Pinterest für vollständige Typprüfungen Laufzeiten von 3 bis 4 Minuten. TypeScript 6.0 war das letzte JavaScript-basierte Release, und erst mit dem nativen TypeScript-7-Compiler ändert sich die Geschwindigkeit der Typprüfung bei sehr großen Repositorys grundlegend. Ihr nächster Schritt: Zählen Sie Ihre @flow-Dateien, prüfen Sie, ob die Abdeckung bei 100 % liegt, und wählen Sie anhand dieser Zahl eine Strategie.

FAQs

Sollten $FlowFixMe-Kommentare in TypeScript zu @ts-expect-error oder zu @ts-ignore werden?

Verwenden Sie @ts-expect-error. Beide Direktiven unterdrücken den Fehler in der nächsten Zeile, aber @ts-expect-error, eingeführt in TypeScript 3.9, meldet den Fehler 'Unused @ts-expect-error directive', sobald diese Zeile keinen Fehler mehr verursacht. Veraltete Unterdrückungen werden so sichtbar, wenn sich die Typen verbessern, und die Zahl der Unterdrückungen kann nur sinken. @ts-ignore bleibt auch dann stumm, wenn das eigentliche Problem behoben ist, und kann daher neue Fehler in derselben Zeile verbergen.

Was ersetzt Flows $PropertyType und $ElementType in TypeScript?

Indexed Access Types, die in Flow und TypeScript dieselbe Syntax verwenden: Aus $PropertyType<T, 'k'> wird T['k'] und aus $ElementType<T, K> wird T[K]. Flow hat beide Utilities in Version 0.155 als veraltet markiert und in Version 0.266 entfernt. Wenn Sie vor der Konvertierung den Autofix use-indexed-access-type aus eslint-plugin-fb-flow ausführen, können Codemods diese Typen unverändert übernehmen.

Führt der Austausch von @babel/preset-flow gegen @babel/preset-typescript eine Typprüfung des Codes durch?

Nein. Babel entfernt TypeScript-Typen, ohne sie zu prüfen, daher braucht die CI weiterhin einen separaten Schritt mit tsc --noEmit. In Babel 8 ist die Option onlyRemoveTypeImports von preset-typescript standardmäßig auf true gesetzt, sodass Babel nur Importe entfernt, die mit import type gekennzeichnet sind. Normale Importe von reinen Typ-Bindings bleiben in der Ausgabe erhalten und können die Modulauflösung stören. Kennzeichnen Sie diese Importe mit import type oder setzen Sie onlyRemoveTypeImports auf false, um das Verhalten von Babel 7 wiederherzustellen.

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.