12k
All articles

Migrating a Large Codebase From Flow to TypeScript

Compare big-bang and incremental Flow to TypeScript migration strategies, with guidance on codemods, running both checkers, type mappings, and keeping progress on track.

OpenReplay Team
OpenReplay Team
Migrating a Large Codebase From Flow to TypeScript

To migrate a large codebase from Flow to TypeScript, choose between two strategies before touching any code. The first is one codemod run across the whole repository, which suits code that is already fully Flow-typed and can survive a short freeze. The second is an incremental, leaves-first conversion that keeps both type checkers running for months or years.

Every Flow upgrade takes too long, the libdefs you need are missing or stale, and new hires keep asking why the repo isn’t on TypeScript. Stopping feature work for a quarter is still not an option.

This playbook compares the two strategies, covers how Flow and TypeScript coexist during an incremental migration, maps the Flow-only types that codemods can’t convert cleanly, and describes how to keep the effort from stalling.

Key Takeaways

  • Pinterest converted 3.7 million lines of Flow to TypeScript in eight months with one codemod run, and confirmed the Babel output from both type systems was byte-for-byte identical.
  • Yelp moved 1.4 million source lines of code package by package over three years and seven months, raising type coverage from 83.15% under Flow to 96.44% in TypeScript.
  • TypeScript has no exact object types. It rejects extra properties only when an object literal is assigned directly to a typed target.
  • Flow’s opaque type becomes a branded type in TypeScript, and $Shape<T> becomes Partial<T>, which Flow itself now recommends.
  • Incremental migrations stall after the eager teams finish. Ranking the remaining modules by how many others they unblock, and setting dated deadlines, keeps them moving.

Why Teams Still Migrate From Flow to TypeScript

Teams leave Flow because the ecosystem around it has shrunk. TypeScript types for third-party packages are easy to find, the tooling assumes TypeScript, and every year on Flow adds to the eventual migration cost. In May 2021 the Flow team said it would prioritize Facebook’s internal codebase over the open-source community. Yelp gave that announcement as a deciding factor, along with DefinitelyTyped’s much larger catalogue compared with flow-typed and the growing number of packages that ship their own TypeScript declarations.

The cost of staying also shows up in tooling. After leaving Flow, Yelp was able to adopt swc and @typescript-eslint. Some Flow codebases also use PropTypes. That is a separate layer: PropTypes validate props at runtime, while Flow and TypeScript check types at build time, so decide on PropTypes separately from the checker migration.

Big-Bang Codemod or Incremental: Which Strategy Fits?

A big-bang codemod migration suits a codebase that is already fully Flow-typed and can tolerate a short freeze. An incremental migration suits a monorepo where feature work cannot stop, and the price is running two type checkers side by side for months or years.

Big-bang codemodIncremental
PrerequisiteFull Flow coveragePackages with clear dependency edges
FreezeShort, at cutoverNone
Calendar timeMonths of preparationYears
Peak effortAutomation script and validationInterop toolchain and outreach
Risk concentratesCutover dayThe long tail
Typical failureBuild or path breakage at mergeStalling at partial completion
ReferencePinterest: 3.7M lines, eight monthsYelp: 1.4M SLoC, 3 years 7 months

Pinterest reached 100% Flow coverage first, which made a single commit feasible. Its team then ran Stripe’s flow-to-typescript-codemod, which works in two passes: a Babel-based convert command rewrites Flow syntax as TypeScript, and a fix command built on ts-morph then repairs, suppresses or reports the TypeScript errors left over. Pinterest rebuilt the TypeScript branch from master every day, more than 60 times by cutover, and put all manual fixes into one cherry-picked commit. Factorial, in a February 2022 post, described a hybrid route. It converted one decoupled area first to show that Flow and TypeScript could run together in production, then converted the rest in one shot with Khan Academy’s flow-to-ts.

Both Flow-to-TypeScript strategies can use the same equivalence check. Type annotations are stripped before code runs, so a correct conversion produces the same JavaScript. Pinterest diffed the Babel output of both sources to prove it. Anything that does break production usually comes from renamed files, updated globs and build configuration. Pinterest found almost 100 hardcoded .js references. Reviewing production sessions after each cutover shows pages that stopped rendering and chunk-load errors that static checks miss.

Running Flow and TypeScript Side by Side

In an incremental migration, converted TypeScript modules must never import unconverted Flow code. Conversion therefore starts at the leaves of the dependency graph and moves upward. Yelp used flowgen to turn each converted package’s .d.ts output into Flow-compatible types for the Flow code that still imported it. It found no tool that went the other way and generated TypeScript declarations from Flow, which is why it had to start with the deepest internal packages. Flow reads a .js.flow file in place of the .js module beside it, so that is a natural place for flowgen’s output. Meta’s flow-api-translator can now emit TypeScript definitions from Flow source, but its authors aim it at publishing libraries that stay in Flow, not at moving off Flow.

Keep unconverted Flow files out of the TypeScript program and let Flow alone check them. allowJs and checkJs are for plain JavaScript, not Flow-annotated files. Move ESLint rules over as each module converts (Pinterest mapped flowtype/no-weak-types to @typescript-eslint/no-explicit-any), following a typescript-eslint setup. Configure editors against the workspace TypeScript version.

Stop the Flow surface from growing with a CI check:

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

The no-new-flow.js script lists files the branch adds relative to the base ref and fails the job if any of them contain @flow. When renaming, use git mv src/foo.js src/foo.ts and commit renames separately from content edits so git log --follow keeps the history.

Flow Types That Don’t Translate Directly

Four Flow constructs have no one-to-one TypeScript equivalent: exact objects, variance sigils, opaque types and $Shape.

Exact Object Types

TypeScript has no exact object types. A Flow exact object becomes an ordinary object type, and TypeScript rejects extra properties only when an object literal is assigned directly to that type. Flow object types have been exact by default since 0.202. {| |} is the older explicit syntax, and ... marks an inexact object.

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

Flow’s + property sigil maps to TypeScript’s readonly. The - (write-only) sigil has no TypeScript equivalent. TypeScript does offer in/out modifiers on generic type parameters.

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

Opaque Types

Flow’s opaque type has no TypeScript keyword equivalent. The usual replacement is a branded type: an intersection of the base type with a unique marker property that only a designated constructor function can produce. A Flow opaque type is transparent inside its defining file. A brand is enforced everywhere, so the constructor needs a 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 deprecated $Shape as unsafe, and since Flow 0.201 recommends Partial<T>, which maps directly to TypeScript’s Partial<T>. Check any call site that depended on other $Shape behavior.

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

The simpler Flow-to-TypeScript type mappings are direct:

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

Replacing flow-typed Libdefs

Once a package is converted, delete its flow-typed libdefs. Use the types the dependency bundles through its package.json types field where it has them, and install @types/* from DefinitelyTyped where it doesn’t. Hand-written libdefs for internal or unmaintained packages have to be rewritten as .d.ts files. Start those early, because they block every consumer.

Measuring Progress So the Migration Doesn’t Stall

Incremental migrations stall once the enthusiastic teams have finished. What moved Yelp’s last 200 or so packages was ranking the remaining Flow modules by how many others they unblock and publishing dated conversion lists. Track three counts every week:

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

Also measure type coverage before and after. Score each blocked module by how many Flow modules are waiting on it, and bring the top of that list to its owning teams. Yelp set quarterly deadlines leading to an end-of-life date. The date slipped one quarter, but the pressure worked. Plan an auto-suppression fallback for the last stragglers: about 2,000 of Yelp’s roughly 6,000 @ts-expect-error directives came from that final step.

Realistic Scale: Months, Not a Sprint

A large Flow-to-TypeScript migration takes months at minimum. Pinterest’s codemod route took eight months, and Yelp’s incremental route took over three and a half years. Enable strict flags gradually once the conversion is done. For the JavaScript-based compiler (TypeScript 6.0 and earlier), Pinterest raised Node’s heap limit to stop tsc running out of memory. Its post doesn’t give the value it used, so treat the number below as an example:

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

Size that value to your machine. Even so, Pinterest reported full type checks of 3 to 4 minutes. TypeScript 6.0 was the last JavaScript-based release, and the native TypeScript 7 compiler is where type-check speed on very large repositories changes. Your next step is to count your @flow files, check whether coverage is at 100%, and choose a strategy based on that number.

FAQs

Should $FlowFixMe comments become @ts-expect-error or @ts-ignore in TypeScript?

Use @ts-expect-error. Both directives suppress the error on the next line, but @ts-expect-error, added in TypeScript 3.9, reports an 'Unused @ts-expect-error directive' error once that line stops failing. Stale suppressions therefore surface as types improve, and the suppression count can only fall. @ts-ignore stays silent after the underlying problem is fixed, so it can hide new errors on the same line.

What replaces Flow's $PropertyType and $ElementType in TypeScript?

Indexed access types, which use the same syntax in Flow and TypeScript: $PropertyType<T, 'k'> becomes T['k'] and $ElementType<T, K> becomes T[K]. Flow deprecated both utilities in version 0.155 and removed them in 0.266. Running the use-indexed-access-type autofix from eslint-plugin-fb-flow before conversion lets codemods carry these types over unchanged.

Does swapping @babel/preset-flow for @babel/preset-typescript type-check the code?

No. Babel strips TypeScript types without checking them, so CI still needs a separate tsc --noEmit step. In Babel 8, the onlyRemoveTypeImports option of preset-typescript defaults to true, so Babel removes only imports marked with import type. Plain imports of type-only bindings stay in the output and can break module resolution. Mark those imports with import type, or set onlyRemoveTypeImports to false to get the Babel 7 behavior back.

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.