Migrating 3.7 Million Lines of Flow Code to TypeScript
Authors: Jack Hsu | Staff Software Engineer, Core Web Platform; Mark Molinaro | Staff Software Engineer, Code and Language Runtime
Pinterest migrated 3.7 million lines of code from Flow to TypeScript in eight months, leading to better type safety, developer experience, and improved hiring. Along the way, we learned a lot and greatly benefited from the open source community, and it’s our turn to give back. Today, we’re excited to share our learnings and contributions to Stripe’s flow-to-typescript codemod!
Why?
In 2016, Pinterest began adding types to our JavaScript codebase. At that time, we chose Flow over TypeScript for several reasons:
- Gradual Adoption: Flow allowed for easier incremental adoption, which was crucial since we had no static type checking and needed to add types file-by-file.
- Working with React: Flow, which was open sourced by Meta, appeared more future-proof — particularly with React, which was also open sourced by Meta — promising seamless integration.
However, over the years, the industry settled on TypeScript as the standard for JavaScript type checking. This is reflected in npm download trends:
In late 2023, we decided to adopt TypeScript for the following reasons:
- Feasibility: We achieved 100% Flow coverage and a variety of OSS Flow-to-TS migration tools were released, meaning we could migrate in a single (massive) commit!
- Better Community Support: In terms of both community and libraries.
- Language Features: TypeScript provides better language features, such as conditional types, const assertions, non-null assertions, etc.
- Talent Availability: It’s easier to hire TypeScript-proficient developers.
How we did it
After researching prior migrations by companies such as Zapier, Airtable, and Stripe, we decided to employ a similar “big bang” approach: migrating the entire codebase at once using a codemod.
The migration can be divided into three key phases: (1) Setup, (2) Conversion, and (3) Integration.
1. Setup
We began by installing Typescript and @typescript-eslint, and proceeded with the the following configurations:
-
tsconfig.json
: We configured it to be strict, in line with the recommendations from the popular TSConfig Cheat Sheet, though making a few exceptions in the interest of time. -
package.json
: We configured it so that runningyarn tsc
would execute asNODE_OPTIONS= — max-old-space-size=<memory in MB> yarn tsc
. This prevents out-of-memory (OOM) errors with the TypeScript compiler. -
.vscode/settings.json
: We modified settings for Visual Studio Code, our officially supported IDE for web, for better TypeScript support:
typescript.tsserver.maxTsServerMemory: <memory in MB>,typescript.enablePromptUseWorkspaceTsdk: true,typescript.tsdk: "node_modules/typescript/lib",
typescript.tsserver.experimental.enableProjectDiagnostics: true,
We found @typescript-eslint
was incompatible with the version of ESLint we were using. Additionally, newer versions of ESLint were not compatible with esprint, our in-house ESLint runner designed for fast linting. Fortunately, Discord’s ESLint fork provided the perfect alternative. Adopting this fork resolved the ESLint upgrade blockage and improved the full-project ESLint linting latency by 40% (from 120s to 70s).
2. Conversion
We aim to convert Flow to TypeScript in a single automated run. The steps include migrating dependencies, running codemods, suppressing ESLint errors, and automating the entire process.
2.1 Migrate type dependencies
We needed to add TypeScript support for all our auto-generated types:
-
Apache Thrift (cross-platform data structures): The generated types remain largely unchanged, except for adding the
as const
assertion to TypeScript enums and objects, which ensures TypeScript can infer the types as narrowly as possible. - OpenAPI (REST): We modified the OpenAPI type generation jobs to generate TypeScript types instead of Flow types. Using openapi-typescript made this transition simple. We only had to build a thin wrapper built around it.
- Relay (GraphQL): Relay offers native support for TypeScript. However, for the usage of Relay’s React hooks, we needed to rewrite most of them due to significant differences in type definitions for React hooks.
**2.2 Run code conversion codemod
**The goal of the code transformation step is to convert Flow syntax to valid TypeScript syntax without altering any code logic. We achieved this using the “convert” feature in the typescriptify
codemod:
The codemod skips files that trigger foundDeclarationFile
warnings, so we double-checked the resulting CSV file to ensure all files were converted.
While the codemod can successfully handle the majority of cases, we needed to implement some patches for specific use cases, including but not limited to:
- Handling more syntax:
T[K]
,$Partial
,$ReadOnlySet
,$ReadOnlyMap
. - Fixing bugs with maybe functions:
?() => void
- Fixing bugs with interaction types:
(A | B) & (C | D)
- Fixing false positives of private types
- Improving handling of flow-related comments
- Improving support for
react
andreact-router-dom
types
Additionally, we developed an ESLint autofix rule to differentiate React.Node
and React.Element
from the global DOM types Node
and Element
:
import { type Node, type Element } from 'react';
import { type Node as ReactNode, type Element as ReactElement }
from 'react';
**2.3 Run TypeScript error suppression codemod
**The goal of the error suppression step is to suppress all TypeScript errors using @ts-expect-error comments
, ensuring no type errors. We achieved this using the “fix” feature in the typescriptify
codemod:
NODE_OPTIONS=--max-old-space-size=<memory in MB> yarn typescriptify fix
--autoSuppressErrors --config tsconfig.json --path /path/to/codebase/
A common issue with the codemod is that it can insert TypeScript error suppressions below eslint-disable-next-line
, which expect the error to be on the next line:
To resolve this, we wrote a script to swap the order of the error suppressions:
**2.4 Suppress ESLint errors
**ESLint’s autofix is another powerful tool for enhancing TypeScript code quality:
**2.5 Automate the conversion
**We wrote a script to automate all the steps mentioned above. Inevitably, the codemod won’t catch all issues. We consolidated all the manual fixes into a single commit, which we added as a cherry-pick commit command to the end of the automation script.
By running the automation daily, we significantly minimized merge conflicts and the number of issues requiring manual intervention. On rollout day, the final automation run was stress-free, as we had already executed the process numerous times with ease.
3. Integration
We aim to adapt all our existing systems to function within the new TypeScript environment in a manner that maintains both forward and backward compatibility. The steps include TypeScript transpilation and updating JS filename references.
**3.1 TypeScript Transpilation
**Our codebase already utilizes Babel for transpilation from Flow in addition to a collection of in-house transpilation plugins. With a quick introduction of babel-plugin-transform-typescript and the requisite fiddling of configs, we completed the majority of our transpilation work. After implementing a few additional adjustments to our custom plugins to ensure compatibility with both Flow and TypeScript transpiled outputs, we could successfully transpile the codebase with minimal differences in the output. You can find the steps we took to validate this output against our current Flow setup in the Validation section below.
**3.2 Updating JS Filename References
**With confidence that the transpiled output matches the existing one, we must be close to the finish line, right? Alas, we still had a whole host of fixups to be made to ensure parity across our build & CI systems. As Tyler Krupicka noted during Stripe’s migration, not only are we changing the type annotations in each file, but we are also changing the file names (or, really, extensions) as well!
Tracking down all the places we relied on filenames was laborious, as we found many cases that required integrations with systems even outside our codebase. Below are a few types of issues we had work through:
_In-code JS filename references
_Across our webpack, linting, and testing configuration, in addition to a bunch of ad-hoc automation, we historically made assumptions about which file types included source code. A large swath of scripts and tools that needed to find all source code had some logic like if filename.endsWith(“.js”)
or glob(“**.*.js”)
.
While using Flow, all source files were indeed .js
files. However, our React/JSX configuration allowed for JSX syntax without a .jsx
extension. Ultimately, we had to track down almost 100 in-code references to JS files and modify them all to check both .ts
& .tsx
(in addition to the currently-present .js
) files.
_Service-side JS filename references
_We utilize Cypress for our end-to-end (E2E) tests, along with a complementary in-house service (Metro) to track test outcomes over time, identify flaky tests, and overall support our testing efforts. Metro’s data model, however, keys everything off of the test path (with extension included). To keep the steamship rolling, we shimmed all our interactions with the metro service to account for sending and receiving the expected file paths on the client side. It helps that there was only one client which was totally in our control.
_File-system based routing JS filename references
_We uniquely identify every page on pinterest.com with a HandlerId
. Historically, this HandlerId
was simply the relative path to the source file for each page. During local development, this setup allows us to lazily build pages by using the HandlerId-as-a-filepath
as a Webpack entry point, building the page only when it is requested. As valuable identifiers tend to do, this build-time ID ended up being used across various monitoring, logging, and alerting systems.
Just as above, the integrations with other systems demanded we maintain consistency with our handler IDs. We achieved this by encapsulating the handler in a new container so we could more precisely track down all its uses instead of string-hunting. From there, we added a similar shim based on the system of interest — in build we look for TS file, in reporting we keep it JS. Maintaining consistency in HandlerId
also provided the additional benefit of enabling a side-by-side comparison of all our metrics when rolling out our first Typescript payload!
Validation
Extraordinary claims require extraordinary evidence. To demonstrate that the migration of million lines of code functioned as intended, we tested the entire testing trophy:
-
Daily Automated Testing: We regenerated the TypeScript branch from the
master
branch and validated it daily. By the time of the cutover, we had generated and validated the branch over 60 times. - Multiple Rounds of Manual Testing: Our quality assurance team conducted three full rounds of manual testing, during which no significant issues were found. Before the rollout, we also temporarily deployed the TypeScript branch to a small percentage of production traffic to identify production-specific problems early.
- Byte-for-Byte Static Analysis: Since runtime execution is determined by the Babel-transpiled JavaScript code, rather than the Flow/TypeScript source code**,** we performed a byte-for-byte comparison between the JavaScript code transpiled from Flow and that transpiled from TypeScript, verifying that they were equivalent.
Rollout
Given the scale of the migration and the number of engineers affected, we needed to address several key considerations:
- Separate TypeScript Changes: How can we isolate the migration changes from others, making it easy to identify any issues related to the migration?
- Minimize Developer Disruption: How can we minimize the time developers are blocked during the migration process?
- Enable Quick Recovery: How can we quickly recover if something goes wrong?
After careful consideration, we developed the following rollout plan:
On Thursday, after merging the TypeScript branch, we encountered failures in CI jobs triggered specifically by merges to the master
branch. Fortunately, we quickly identified the root cause, implemented a forward fix, and unlocked the repository without any delays.
On Friday, we deployed the changes to our canary environment and re-ran all automated tests. While some tests failed initially, we quickly identified the root cause as an unrelated issue stemming from a backend service deployment. After resolving this, we achieved a successful deployment, marking Pinterest’s official transition to TypeScript!
Deploying on a Friday might seem unconventional, but we were confident in the success of the migration and prepared to address any issues over the weekend if necessary. We chose Friday because, should any issues arise and be discovered late, they would have a smaller impact due to the lower website traffic typically experienced during weekends, compared to the weekdays.
After a quiet weekend, as expected, we lifted the code freeze and returned to business as usual. The entire rollout was incident-free!
Reflections
-
Smooth Migration: 97% of survey¹ respondents rated the overall migration experience as positive.
-
Comprehensive developer education: We hosted several internal engineering training sessions, and 64% of survey respondents found the training helpful.
-
More Intuitive Errors: 85% of survey responders found TS errors easier to understand.
-
Better Library Types: We are able to achieve more third-party library type coverage with less code.
-
Latest & Greatest TypeScript Features: Compared to the past Flow upgrades, TypeScript upgrades have been significantly easier.
-
Slow Type check: Performing a full type check on our entire codebase took about 3–4 minutes. Our profiling indicated that a high memory footprint was the primary cause. While we managed to reduce some latency by following the TypeScript performance tracing wiki, further optimization proved challenging.
– This March, TypeScript team announced 10x performance improvements, and we are hoping the performance problem will be addressed soon.
This migration was one of the largest projects in terms of scale and impact at Pinterest, and we believe it was also one of the most successful. We gained numerous insights and achievements, and we hope the information shared in this blog post will serve as a valuable resource for other companies undertaking similar migrations.
Kudos
We are thankful for:
- The Stripe team (Tyler Krupicka, Ken Deland, Russill Glover, Ben Bayard, Andrew Lunny) and the Airtable team (Caleb Meredith and Andrew Wang) for both the open-sourced migration codemod and the blog posts,
- Alberto Carreras Carrasco, Andrew Lutz, Kessy Similien, and Yen-Wei Liu for their code contributions and code reviews,
- Alice Yang, Joao Bueno, Jorge Roberto Rojas Villarreal, Juan Benavides, and Vikrant Maniar for their help with testing,
- Scott Hebert, Vasa Krishnamoorthy, and many others for the support/feedback.
- Jordan Cutler, Mark Cerqueira, Robert Balicki, and Vasa Krishnamoorthy for reviewing the blog post.