Type-safety while incrementally migrating a large app from Flow to TypeScript

Andrew Huth
CZI Technology
Published in
4 min readFeb 24, 2022
Computer on a desk with JavaScript on the screen

At CZI our mission is to build a future for everyone. One of our apps helping accomplish that is the Summit Learning platform. Its frontend is about 470k lines of Flow, which we’re migrating to TypeScript.

Goodbye Flow, hello TypeScript

Both Flow and TypeScript are type systems for JavaScript, which help catch bugs and make our lives easier as programmers.

Flow served us well for a long time, but time marched on and TypeScript gained in popularity. These days TypeScript is better supported by tools such as Storybook or integrated development environments (IDEs), as well as by third-party libraries.

For these and other reasons, we decided that TypeScript is the best match for our use-case moving forward.

So we set forth migrating.

Migrating, gradually

We’re not the first organization to migrate to TypeScript, and there are plenty of articles on the internet about doing so.

Many of those articles focus on automatically converting an entire codebase at once. While that’s a viable strategy, we didn’t want to touch essentially every single file in one go.

Instead, we’re opting for an incremental migration, where we migrate one page or feature at a time. Automatic migration tools such as flow-to-ts are still very useful for helping with this.

Incrementally migrating makes sense for us because

  • Less code changes at once, which feels safer
  • Product teams own migrating their code
  • Migrating code provides an opportunity for people to learn TypeScript

This is the right call for us, but there is a problem. Until the migration is complete, how do we handle being partially migrated? What happens when a Flow file imports TypeScript and vice-versa?

Interoperability between Flow and TypeScript

While migrating, Flow and TypeScript files need to talk to one another. Otherwise, we can lose type safety, or the app may not even compile.

I haven’t found too much information about doing this online (probably because most articles seem to focus on converting the entire codebase at once). To solve this, we came up with a couple of techniques that work well.

Type annotations are often identical between the two

Many type annotations are the same in both Flow and TypeScript.

For example, this code is valid in both type systems:

// isEven.ts
// @flow
export default function isEven(num: number): boolean {
return num % 2 === 0;
}

Everything “just works” if we configure Flow to read .ts files and add an @flow at the top. Flow can read this TypeScript file, which means Flow files can import it!

But (you knew there was going to be a but), there’s a problem with this, too.

It works for simple files, but what if we want to use more complex types where Flow and TypeScript aren’t compatible?

Define separate Flow types where necessary

Not all Flow and TypeScript types are compatible. A common example is React’s types.

React’s type definitions for TypeScript include ReactNode, but the same thing in Flow is Node. Same type, different name.

One way around this is to write a file in TypeScript (without worrying about interop with Flow) and provide separate Flow types.

The TypeScript file might look like this:

// button.ts
import type {ReactNode} from "react";
type Props = {
children?: ReactNode; // <- TypeScript’s type
}
export default function Button({children}: Props) {
return <button className="btn">{children}</button>;
}

We can then define a Flow declaration file with the same name (but a different file extension).

// button.js.flow
import * as React from "react";
type Props = {
children?: React.Node; // <- Flow’s type
}
export default function Button(props: Props): React.Element;

Flow files get Flow types, and TypeScript files get TypeScript types. Everyone is happy!

This works great, but I know what you’re thinking… Am I really suggesting you maintain two files everywhere we have incompatible types?

Nope, not if we can help it.

Consolidate incompatible types into one file

My coworker André Malan came up with an elegant solution to make this easier.

  • Isolate incompatible types in a single TypeScript file
  • Provide a Flow declaration file for just that file
  • Import types from there

For the React types example we used, the TypeScript would be:

// flowCompat.ts
import React from "react";
export type $TSFixMeReactNode = React.ReactNode;

For Flow:

// flowCompat.js.flow
import React from "react";
export type $TSFixMeReactNode = React.Node;

Our main file can now be written in Flow-compatible TypeScript by importing from these types:

// button.ts
// @flow
import type {$TSFixMeReactNode} from "./flowCompat";
type Props = {
children?: $TSFixMeReactNode;
}
export default function Button({children}: Props) {
return <button className="btn">{children}</button>;
}

Both Flow and TypeScript can use this file and get types they understand.

This technique won’t work for everything (for example, generic type constraints). But in our experience, it works great for many files and React components.

Now it’s possible to migrate our codebase incrementally while maintaining type safety.

And of course, we look forward to the day when the migration is complete, and we can delete the Flow-compatibility types.

--

--