Hello TypeScript!
by @z0isch on October 24, 2023
Freckle is proud to announce that we have successfully migrated our frontend codebase of over 300k lines of code from Flow to TypeScript! This project began way back in March 2021 and employed several different strategies and tools to make the migration possible. This post will dig into some of the details on how and why we decided to make the switch.
Why Migrate from Flow to TypeScript?
Here at Freckle we love strong static type systems and were an early adopter of Meta’s Flow. Forwards 5 years, while Flow has some novel features, including things like opaque types, we felt that the tooling and support that TypeScript receives from the community was too substantial to ignore. This coupled with reading some success stories of Flow -> TypeScript migrations inspired us to begin looking into doing our own migration around March 2021.
The Big Bang
Our initial approach was to migrate the entire codebase in one fell swoop. This seemed ideal- one day we would be on Flow and the next on TypeScript! After looking into some of the existing tools we settled on using the fabulous flowts utility. Our initial tests on small parts of the codebase looked great, but after using it over the whole codebase it was plain to see that we would have to use @ts-ignore
, the dreaded any
type, and other unsafe techniques to accomplish it.
These unsafe techniques can be reasonable if they are short-lived, but we feared that the team would not be able to address them promptly. There are few things scarier in programming than a type system that has holes in it.
And Now For Something Different
After realizing that the tools and techniques for the Big Bang style migration did not give us the safety guarantees that we were hoping for, we had to look for other strategies.
Any iterative approach requires being able to maintain both Flow and TypeScript code at the same time. If done without careful consideration this implies maintaining bi-directional interfaces:
- Flow importing TypeScript => TypeScript needs to generate Flow interfaces
- Conversely, TypeScript importing Flow => Flow needs to generate TypeScript interfaces
We can avoid this situation by being a bit smarter when picking which files to migrate. By only migrating modules with no imports that use Flow, the second implication is removed: TypeScript modules will only require other TypeScript modules and no Flow module will need to generate a corresponding TypeScript interface.
This led to the following approach:
- Spin off utility modules into packages and migrate independent of the main codebase
- Identify and migrate leaf modules with no Flow dependencies
- Migrate each new set of leaves after each iterative migration
We used flowts to generate the TypeScript from Flow and used flowgen to generate Flow interfaces from TypeScript.
Spin Out Libraries
Frontend apps at Freckle are split between three teams: teacher, student, and internal. As you can imagine we share a lot of code between the teams; however we have never formally encoded them as separate packages.
The small size and stability of these packages made a great testbed for trying out flowts
and flowgen
. This, coupled with advancing a separate engineering goal of providing clear boundaries around the code each team owned, made this technique attractive.
Here’s a list of the open source projects that we have spun out:
- @freckle/non-empty
- @freckle/maybe
- @freckle/parser
- @freckle/resource-status
- @freckle/query-params
- @freckle/i18n-scripts
- @freckle/gen-flow
- @freckle/exhaustive
- @freckle/cancelable-promise
- @freckle/ajax
Turn the Crank
To understand where best to start migrating the codebase, we used the madge tool to model files as nodes in a graph. Some of those files have no dependencies on other files a.k.a leaf nodes. Thinking about leaf nodes led us to a “turn the crank” style migration:
- Use
madge
to identify leaf nodes (those that have no dependency on Flow) - Migrate those leaf nodes with
flowts
- Generate Flow interfaces from the TypeScript implementation using
flowgen
- Turn the crank! Go to step 1
This style resulted in small bite-sized PRs that could be reasonably reviewed and led to a migration that introduced few if any bugs into our codebase. While this migration gave us a high degree of safety, it did not come without downsides.
Here are a couple of issues that we encountered:
- Engineers needing to work in both Flow and TypeScript
- Slow compile times
- Running both Flow and TypeScript compilers along with
flowgen
on every change was quite slow - This was exacerbated as we neared the end of the project due to the growing amount of TypeScript
- Running both Flow and TypeScript compilers along with
- Increased complexity to bundle the frontend using both Flow and TypeScript
- Circular module dependencies do not conform to the migration strategy that we used-there aren’t any leaf modules in a cycle!
- Luckily our usage was limited enough that we could migrate entire cycles in one pass effectively cutting out the knots in their entirety.
Hello TypeScript!
2 years and 300k LOC later we can finally say “So Long!” to Flow and “Hello!” to TypeScript. Pulling off a migration of this scale is much more viable through the use of tools from a robust community of open source maintainers and contributors. In particular we’d like to thank the maintainers of flowgen
,flowts
, and madge
for making this migration possible.