Migrating the Sanity.io codebase from Flow to TypeScript

Written by Bjørge Næss

We recently migrated the @sanity-packages written with Flow to TypeScript. It was an interesting journey and we'd like to share some experiences about how we ended up (re)configuring our build pipeline, how we dealt with CSS modules and how we managed to keep focus on compatibility in order to lay a solid foundation for the future of the Sanity.io codebase.

What is Flow? And what is TypeScript?

TypeScript and Flow both add support for static typing to JavaScript. Static typing means that the compiler (or even the code editor) will tell you about potential errors in your program, as opposed to dynamic typing where type errors are checked at execution time.

Why static types?

A statically typed language enables you as a developer to encode expectations about the structure of data types, variables, function arguments and return values in your code. A compiler will then tell you when these expectations are broken, thus giving early feedback about potential errors.

For example, it enables you to declare the structure of the props a component is expecting, and if you try to use a React component in a way that doesn't match these expectations, you will get feedback a lot earlier than you would have otherwise, (e.g. immediately as you write the code instead of when running it).

Below is the kind of feedback an editor can give you instantly as you type. Without static typing you would be relying on catching this error while testing or running the application, and noticing that your Hello component didn't work as expected.

A screenshot of an IDE showing a type error

Not only does this mean you get earlier feedback when developing code, but it also leads to safer, more robust and less error prone code, and increased maintainability.

A short history of the Sanity.io codebase

The first commit of what eventually became Sanity.io can be traced back to July 2014.

This doesn't mean that the oldest components haven't been modified since it was written. Quite the opposite. When we started out, this was the current state of Web development with React:

Staying ahead of tech has always been important to us, and over the years our codebase has been modernised gradually. A lot of great tools have eased this modernisation process. Most notably the codemod scripts made available by the React community have enabled us to migrate from React.createClass() to class components, etc.

As our codebase has been growing at a faster pace than our (still small) team of developers, we started experimenting with adding static typing to our codebase a couple of years ago. At the time, Flow, the static type checker developed by Facebook, seemed like a great choice because of its great support for React and seamless integration with our existing toolchain (Babel, ESLint, etc.), so we started using it for a few packages. It helped us a lot in thinking about types, but we also spent a considerable amount of time battling configuration and dealing with third party library typings.

Since then, TypeScript has matured a lot and even though Flow is still great and has also improved over the years, it now it feels like TypeScript has won. Also, our reasons for choosing Flow over TypeScript in the first place has been voided with the advent of Babel getting TypeScript support in v7, and with the recent TypeScript support in ESLint.

We now felt the time was ripe for getting rid of Flow.

Note: We're not getting into a deeper comparison of Flow vs TypeScript, as there are other and better resources for that.

The grunt work

Migrating from Flow to TypeScript was a bumpy journey, but a lot of great tools helped a lot, especially the flow-to-ts tool from Khan Academy. But even after running it, it took a lot of careful effort to make everything pass TypeScript's type checker. And then there were the realisations we had along the way...

Can't we just use Babel for everything?

We already had some TypeScript packages, for which we were using the TypeScript compiler. All our other packages were built using Babel.

We <3 Babel and have been using it since it was called 6to5. We have been using it to compile React/JSX, and it's enabled us to use new JavaScript language features before they were supported by every browser or runtime that we officially support. It also integrates well with ESLint, Webpack, and other tools we rely on, so getting rid of it would mean that we'd miss out on some of these features.

Still, maintaining two different build pipelines isn't exactly optimal, and Babel has had TypeScript support for a while already.

So to begin with we wanted to see if we could get rid of our existing TypeScript compile pipeline...

But no. Although Babel has TypeScript support, it only supports parsing TypeScript and compiling to plain JavaScript with type information omitted. A large part of the value of TypeScript comes from the ability to ship TypeScript Declaration files with your packages. Since we couldn't get that from Babel, getting rid of the TypeScript compiler was not an option.

Can't we just use the TypeScript compiler for everything, then?

Since TypeScript is a superset of JavaScript, all JavaScript code is also valid TypeScript. And the TypeScript compiler can be configured to both parse and do inference based type checking on JavaScript code. You can even generate type declarations from plain JavaScript. Here's a great writeup about that if you're interested.

Still, getting rid of Babel would mean getting rid of a lot of our existing tooling. A huge part of the reason is ESLint, which we still rely on for overall code quality and consistency.

Ok, so we're left with two compilers in any case. To minimise the maintenance burden, we then decided to use Babel for compiling all source code, while keeping the TypeScript compiler only for emitting type declaration files.

It was interesting to see how much more type errors TypeScript uncovered compared to Flow. The largest part of doing the migration was actually to fix semantic issues with our existing types.

Part imports

As mentioned earlier, the scope now was to get rid of Flow and improve/harmonise our TypeScript build pipeline. This meant not spending time on improvement of existing types, or adding types to new packages. A problem during this process was to figure out what to do with our part: imports, which is a special kind of module id that our plugin system is built upon. Due to TypeScript's support for declaring export types of modules, it was pretty straightforward to add declarations for just the part imports used in a package by making a d.ts file. Here's an example.

To begin with, all the exports are defined as the any type. The fact that TypeScript gives you the power to do this is great as it removes roadblocks during migration and then allows you to gradually refine the type system over time.

CSS Imports?

We use CSS modules for the Sanity Studio application, which means a few of our @sanity-packages import css files, e.g. import styles from './Component.module.css'. Since TypeScript has no support for understanding the syntax and inferring the exported types from CSS files, the stop-gap measure was to make a TypeScript Declaration file for all files ending with .css. Thanks to the wildcard support, this was just a matter of adding a file with the following:

Internal server error