How I migrated a codebase from FlowJS to TypeScript without interrupting new feature development
I work for ResearchSpace, a small company that makes a Web-based software tool for scientists and researchers in labs. For the past five years all of the JavaScript has been written with FlowJS type annotations, which has helped us catch countless bugs in development, gave us the confidence in our refactoring, and kept our JavaScript code more predictable and self-documenting. However, it has long been clear that TypeScript has become the industry standard for statically-typed frontend development. Type definitions for libraries are often only available in TypeScript and tooling of various sorts assumes that all frontend devs are using TypeScript. With the reasoning that all these LLM tools are likely to be much more helpful with TypeScript than FlowJS annotations due to the corpus of training data, we made the decision to migrate our codebase to TypeScript but with the major caveat that as a small company in a fast-moving space we had to do it with the absolute minimum impact to regular feature development.
Many people have written guides on how to migrate a JavaScript codebase without any type annotations to TypeScript in a piecemeal fashion, but I struggled to find any guides on how to do the same for a codebase that already has type annotations, just in a slightly different syntax. Many of the people that have made the migration from FlowJS to TypeScript in the past described how they were able to make the migration fairly easily (although often on smaller codebases) but had to stop all new development. Not knowing exactly how long it would take us if we were to do that and the difficulty of making the business case for the hit to new development velocity, I instead came up with a plan based on piecemeal migrations from untyped codebases.
My plan relied on the fact that at the end of the day both FlowJS and TypeScript transpile down to JavaScript, with webpack ignoring and stripping out all of the types. Our CI pipeline can run the type checker for both tools but we don't need our build step to do so. So we end up with three separate rules that need to hold for the entire duration of the migration:
- Webpack must know how to strip out the types from both FlowJS and TypeScript and compile the usual minified JS assets.
- The FlowJS type checker must be able to verify all of the files that are annotated as such.
- The TypeScript type checker must be able to verify all of the new
.ts
and.tsx
files.
JavaScript code with FlowJS annotations can call TypeScript code just
fine once webpack knows how to process each module, but to keep the
codebase being typesafe we want the Flow type checker to look for a
matching library declaration file whenever the JS code imports a
.ts
or .tsx
. This is done by adding these lines
to the .flowconfig
file:
module.name_mapper.extension='tsx' -> '\1.js.flow' module.name_mapper.extension='ts' -> '\1.js.flow'
I therefore had a mechanism for migrating JavaScript files that didn't depend on any other files or, once I'd made some progress, only depended on files that had already been migrated to TypeScript: I'd migrate the file and make a new library declaration file to accompany it, ensuring that all the code that relied on this newly migrated module continued to be type checked. I could then come back and migrate the code that depended on it at some point in the future.
Starting with the leaves of the module dependency graph — the utility scripts, the third-party library wrappers, the react components with custom styles — I migrated those to TypeScript and then migrated the files that depended on those, and so on and so on; a wave of migration that sweeped through the codebase. This was a somewhat slow process, but it allowed us to continue to develop new features in the FlowJS code while I migrated the rest of the codebase. The only catch is that where we changed the interface of a module straddling the two regions of the codebase we had to remember to change both the TypeScript file and the accompanying FlowJS library declaration file, though this really didn't happen often in practice.
One problem with this, however, is where there are cycles in the dependency graph. If the dependency graph were a directed acyclic graph (DAG) then we could just start at the leaves and work our way up ending up at the entry points declared in the webpack config, but there were a few places where the code indirectly depended on itself. In these cases we had to break the cycle by either refactoring the code to remove the cycle where it should never have been there in the first place, or by migrating all of the modules that formed the cycle in one go. One such example was the set of react components that implement the search interface in the Inventory system. The searchbox and results table are used inside of the dialogs that implement some of the context actions that are available when a set of results in the main search UI are selected. This self-similarity provides for a consistent user experience and allows for some nice code reuse, utilising nested React contexts for propagating state down the component tree. We definitely wanted to preserve this pattern, which meant migrating a dozen components in one go.
It ended up taking about four months to migrate out 150,000-line codebase, although by design the process was interrupted by regular feature development a few times. I feel confident in saying that if the process had taken us a lot longer — if the codebase we're a lot larger or there was less time to dedicate to the project each week — that we wouldn't have run into any issues with having the codebase be partially migrated for many months; although I wouldn't run the risk of it becoming a perenant fixture of the architecture of the system. The timeline was substantially sped up by utilising dependency cruiser to iteratively find candidate files for migrating next and parallelised agentic LLM tools, using git worktrees and the Zed code editor, which shaved at least a month off the development time, but with how quickly the LLM tooling space is evolving writing anything on the usefulness of particular workflows seems like a futile endeavour.
What I've taken away from this is that a big migration or a substantial re-write can be successfully executed without interrupting regular feature development, as long as a clever and careful approach is taken to tackle the migration piece by piece, module by module, leaving the codebase in a state where it is still functional and type-safe at every step of the way. Ultimately this makes such work a much easier to sell to the wider business, and makes it harder to justify continuing to rely on legacy technologies simply because the migration is too hard. It also means we shouldn't be afraid to adopt unorthodox technologies provided we can plan a smooth off-ramp to something more mainstream in the future, should it be necessary.