Skip to content
TkDodo's blog
TwitterGithub

Flow to TypeScript migration journey

JavaScript, TypeScript, Flow, ReactJs6 min read

race
Photo by Jan Starek
    No translations available.
  • Add translation

It's the end of 2020, and it's hard to imagine frontend development without static types. It is very convenient to start a project without types, because you can allegedly move much faster. For personal projects, or for trying out new things, it also makes perfect sense: Probably, no one else will need to read it or work with it.

But for a professional setting, code readability and maintainability are a must. In that sense, static types fall in the same category as tests:

  • They help document the code
  • They make everyone trust the code more and give safety during refactoring
  • They catch bugs before they hit production

Most people don't write tests for personal projects either (if you do: good for you, I don't 😜), but they very much write them at work. So no matter if you love types or not, I think we can agree that they do provide value in the long run the bigger the project gets and the more people work on it. Also, more and more projects adopt types, so there is really no working around them anymore.

Our journey

Our Journey started somewhere in February 2017. We were a young and small team working on a small-to-medium sized JavaScript codebase for the last couple of years. The team was growing and things were naturally becoming harder to maintain. Our codebase was mostly React with some "legacy" Angular 1 code. The idea was to re-write some existing React code with a statically typed language, and to also add types to all new code that was written.

The type race

Even if you cannot imagine it right now, at that time, TypeScript was not the clear go-to technology when it comes to static typing. Much like during the browser wars, there were rivaling products seeking for adoption. As far as I remember, there were three main competitors:

ReasonML

Reason was quite new at the time, and we didn't really evaluate it. The main problem was that it requires you to learn and write a completely different language, and we wanted something that was more like JustJavaScript™️, but with types.

TypeScript

TypeScript was somewhere around version 2.2 at that time, so it didn't have many of the great features we now love about it. If I remember correctly, the ReactJs integration was not that good either. Maybe we judged that wrong at the time, but it felt like TypeScript was for Angular, so we put our attention towards:

Flow

Maintained by the same company that made ReactJs, this static type checker seemed like a very good alternative. The //@flow pragma made it easy to gradually adopt it, and Facebook was dogfooding it a lot, so it seemed like the "safer choice" - or at least the one that plays better with React.


So we chose Flow and started with version 0.39. We began gradually adding flow types to our existing ES6 codebase, which meant reverse-engineering what could actually be passed to functions, and what they should return in which cases. This wasn't easy, and it became apparent that we were making lots of assumptions, or only coding for sunshine cases.

It also turned out that it's not always possible to "just add types later": Some design decisions (like: having discriminators for your union types) work out differently if you think in types from the beginning. This turned out to be a lesson learned the hard way for future products.

Nevertheless, things were working well, and we were not unhappy with our choice for quite some time. Here and there, flow was not catching bugs that we expected from a static type checker. We also accumulated lots of //@flowignore or //@flowfixme annotations where things just didn't seem to work. Typing higher order components (which were everywhere) was a real pain, and after about two years, our happiness started shift:

  • We had about 50% of our codebase written with flow types, but only about 15% of our third party libraries were actively shipping flow-type-definitions: The rest was just any stubs! This seems to have been a direct result of the community shifting to TypeScript.
  • The flow version we were using did support optional chaining (one of the best additions to EcmaScript), but it didn't yet type narrow when you used optional chaining, making it kinda useless:
1if (foo?.bar) {
2 // nope, not with flow 🚫
3 doSomething(foo.bar)
4}

The final nail in the coffin

"Thanks for nothing, flow" became a meme in the dev department that was used every other day. On top of that, we had launched two other products in the last years which were betting on TypeScript. Satisfaction was very high in those teams, and when our design-system team announced that they would also convert to TypeScript and not ship flow types as a result, we knew we had to act. We investigated two paths:

Upgrade to the latest flow version.

Upgrading flow was always a real pain. We hadn't upgraded much because of it, and the latest version was already 0.119 (srsly, no v1 after 5 years), while we were still on 0.109. Upgrading yielded 2500 new errors and absolute imports didn't work anymore. The flow upgrade tool was of no help to us either.

Move to TypeScript

We evaluated flow-to-ts, which could automatically migrate existing flow types to TypeScript. It worked quite well, but a lot of syntax errors remained. After fixing them with search-and-replace, about 5200 real type errors were left. Oof, that's still a ton! We thought that making the compiler options a bit more relaxed / less strict could help us get the errors down, and we could always strive for better types as we go (Spoiler: This was a mistake. Don't try this at home!). Allowing implicitAny instantly lowered our type errors to 3200. Now we're talking!

👋 TypeScript

We picked up the issue Switch to TypeScript in March 2020. We had one developer working on it mostly full time, and I joined after about 2 months to help with the migration. We progressed slowly because we also wanted to strive towards few runtime changes to minimize the amount of regressions.

Keeping up-to-date with our develop branch was a real challenge. Every time an epic was merged, we had to integrate it to our long-running migration branch. Newly added flow files had to be re-written again, and files that were touched would only show up as deleted by us in git - which meant we had to re-do the adaptions in TypeScript.

As we saw some light at the end of the tunnel, we decided to have new features branch off the TS branch. This meant we couldn't merge them to develop (they were now dependent on TypeScript), which was a bit of a gamble, but we were confident that we could merge the migration before those features were finished. It also made our live a lot easier, and we got some beta-testers as well.

A draft PR was opened on June 3rd, and we finally merged it almost 3 months later:

merged

Case closed?

Not quite. On September 1st, I opened a follow-up issue: no-implicit-any. We cheated a bit in the beginning by allowing implicit any to quickly work around ~2000 type errors. I soon realised that this was likely a mistake when the first TypeScript based PRs came in. I thought that we will be able to fix the implicit anys over time when we see them and could easily live with the lax setting until then, but I drastically underestimated the fact that new code would also lack type safety because of this.

The reason for this is quite simple: If the compiler doesn't scream at you, you might not notice that you need to add types unless you are very familiar with TypeScript. Consider the following React event handler:

implicit-any
1const loginUser = (event) => {
2 event.preventDefault()
3 axios.post('/login', ...)
4}
5
6return <form onSubmit={loginUser}>...</form>

This works, and event is just any here - not what we want for new, type-safe code. I thought this issue could be avoided by just communicating that you have to be careful with things like that, but on a team of ~30 developers with various TypeScript experience, having a tool tell you what to do seemed like the better approach.

ts-migrate

We found ts-migrate, which allowed us to convert most the of implicit anys to explicit anys, turning the above code to:

explicit-any
1const loginUser = (event: any) => {
2 event.preventDefault()
3 axios.post('/login', ...)
4}
5
6return <form onSubmit={loginUser}>...</form>

While it doesn't make this particular, existing code any better (pun intended), we could now disallow implicitAny via tsconfig to avoid such code in the future, and gradually cleanup the explicit anys. I just counted 575 usages of : any in our code base today, so we still have some miles to go.

Takeaways

I don't regret the move to TypeScript at all. TypeScript has emerged as the clear winner for static typing in the frontend world. Most libraries are now written in TypeScript, and if not, it's effectively a must to ship with TypeScript types, either natively or via DefinitelyTyped, if you are seeking adoption. The unified developer experience over all our products makes it a breeze to switch context, so it was definitely worth the troubles.

Stats

Lines of Code migrated119,389
Number of commits1,799
Files changed2,568

Flow, TypeScript or plain ES6? Let me know in the comments below what you prefer. ⬇️