Migrating a 50K SLOC Flow + React Native app to TypeScript

Written by Syz | Published 2019/10/11
Tech Story Tags: javascript | reactjs | babel | flow | react-native-typescript | software-development | programming | typescript

TLDR The Facebook React Native project team chose Flow vs TypeScript for our React Native app almost three years ago. TypeScript has a lot of value to offer making the switch highly attractive. The project team decided to use Flow vs. TypeScript instead of VSCode for their next React project. We used Flow to create a Flow-to-typescript and Babel to convert our configuration from Flow to TypeScript in Babel 7. We also used Babel support for TypeScript with a JavaScript + Flow extension with a.js extension to Type.js.via the TL;DR App

Photo by Sergey Pesterev on Unsplash

“Well, it depends” is the prototypical response when asking a software engineer for advice, no matter how straight-forward the problem may seem. When asked “Should we choose TypeScript or Flow for our next React Native project?” however, the answer only depends on one variable: whether or not you work at Facebook.
It’s interesting to consider how we got here. The project team evaluated Flow vs TypeScript for our new React Native app almost three years ago. At the time, TypeScript didn’t support React well, didn’t allow for a gradual opt-in, there was no Babel support, and VSCode wasn’t the editor providing the best JavaScript development experience on the market.
None of these factors are true today. But why bother to migrate?
TypeScript has a lot of value to offer making the switch highly attractive:
  • More “future proof”
  • Higher OSS adoption & better RN + library support
  • Awesome developer experience (VSCode)
  • Consistency with other department projects
  • Easier to hire for
So, we forged ahead! But how’d it go?

Before: 50K SLOC, 84% Flow Coverage

$ npx sloc app
---------- Result ------------
Physical :  57903
Source :  50859
Number of files read :  709
----------------------------
$ npx flow-coverage-report  -i 'app/**/*.js'
percent: 84 %
$ time flow check
flow 3.64s user 2.31s system 32% cpu 18.458 total

Getting Started

The first step we took was to update our JS tooling configuration. For our project, the procedure was relatively straight-forward, guided mostly by simply copying the configurations directly from a newly instantiated TypeScript template project using the react-native cli.

Prettier & ESLint

{
    "parser": "typescript"
}
One used to have to deal with TSLint when chosing TypeScript but thankfully TSLint is deprecated, so it’s an easy choice in 2019—just stick with ESLint!
Literally no changes necessary (for our usage anyway, and that included plugins and custom rules!)
Done. Next.

Bonus Aside: Removing Black Magic module imports

Did you know that you can import JavaScript modules relative to a package.json file using the name attribute in the json file, no plugins required?
Black magic! But it was useful black magic!
To accomplish absolute path imports (i.e.
import Foo from ‘app/ui/components/Foo')
we included in our project a
package.json
in the
/app directory
(and several others as well for a total of 4 package.json’s):
{ "name": "app" }
This even works when importing from other folders in the tree (meaning you can import from
app/components/Foo
outside of the ‘app’ folder).
Well, TypeScript doesn’t like this black magic fuckery and couldn’t figure out our imports :(. So, we deleted our extra package.json files and chose to use babel instead.

Babel

With built in Babel support for TypeScript in Babel 7, converting our babel configuration from Flow to TypeScript was a process of simply removing unused plugins (like @babel/plugin-transform-flow-strip-types).
Here’s our final Babel.config.js, complete with less magical module resolution.
module.exports = {
    presets: ['module:metro-react-native-babel-preset'],
    plugins: [
        [
            'module-resolver',
            {
                root: ['.'],
                alias: {
                    app: './app'
                },
            },
        ],
    ]
}

tsconfig.json

While not strictly required for running your React Native app (Babel will just strip/ignore the TS syntax anyway), the tsconfig is required for using the TypeScript compiler (and VSCode tooling) to detect type errors.
Thankfully, we got 90% of the way there by simply copy-pasting the tsconfig from a boilerplate new React Native project.
However, we also include some .json files in our project, which require some additional configuration.
{
  "compilerOptions": {
    "baseUrl": ".",
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "jsx": "react-native",
    "lib": ["es6", "dom"],
    "moduleResolution": "node",
    "noEmit": true,
    "strict": true,
    "target": "esnext",
    "resolveJsonModule": true, // Required for JSON files
    "skipLibCheck": true
  },
  "exclude": [
    "node_modules",
    "e2e",
    "**/*.json", // Don't try and check JSON files
    "**/*.spec.ts",
  ]
}

Migrating Flow to TS syntax and .js to .ts/x

Thankfully some work has been done in the open source community to aid in the conversion from Flow to TypeScript syntax, and the projects have support for many of the common language features.
However, I cannot say that these projects tend to be very well maintained or mainstream.
There are two dominant solutions available, flow-to-typescript and babel-plugin-flow-to-typescript. We tried the flow-to-typescript library first but abandoned it because it crashed on any file containing a function (WTF?).
Thankfully the babel-plugin-flow-to-typescript worked for us. Almost.
As of writing the same bug related to function support forced us to use a fork. I don’t know what’s up with these tools and supporting plain old functions but whatever, it worked, I guess I should be thankful!

Convert JavaScript + Flow with a .js extension to TypeScript with .ts:

yarn add global @babel/cli @babel/core babel-plugin-flow-to-typescript
# Convert single file
npx babel script.js -o script.ts --plugins=babel-plugin-flow-to-typescript
# Convert all files and delete original JS after conversion
# Prereq: 'brew install parallel'
find app -type f -name '*.js' | parallel "npx babel {} -o {.}.ts && rm {}"
Note: We actually used @zxbodya/babel-plugin-flow-to-typescript due to a bug in the main repo.
Next, rename any files which import React at the top to use .tsx instead:
find app/components -name "*.ts" -exec sh -c 'git mv "$0" "${0%.ts}.tsx"' {} \;

Sanity Check: Does the app still run?

Before proceeding any further, it’s worth checking at this point that the project actually runs. Because Babel is used to compile TypeScript to JavaScript, and Babel simply strips out all TypeScript related syntax, regardless of how many type errors your project has it should “Just Work.”
In our case, it took less than half a day to update configurations, rename files, migrate all syntax, and get a running app again.
But the work is obviously still far from over.

Fixing 1000s of compilation errors

In our case, the effort began with ~1800 TS compilation errors.
Fixing this amount of errors is not a linear journey where one fix means your errors go down to 1799. Sometimes, making a change will result in dozens fewer errors, making you feel like a god among mortals, and sometimes it increases your overall error count leaving your confidence shaken!

#1—Fixing the Implicit Any

The number one thing TypeScript will complain about is an implicit any. Mostly due to differences between TS and Flow regarding type inference, one must immediately add additional annotations.
Be warned: don’t go simply adding a type explicitly where TypeScript complains. Instead, ask “Why is this thing any?”
// Problem here
const reqs = get(props, 'requests', [])
const inProgressIds = reqs.map(req => req.id)
const activeRequests = actions
    // Error shown here
   .filter(action => inProgressIds.includes(action.id))
Rather than adding a type to
action
as the error suggests,
reqs
is missing a type, producing the error below. The root cause could be another issue in the function, or even an entirely different file!

#2—Updating React components

Easily the least enjoyable and most “hairy” part of the conversion is migrating React code. Errors thrown in React code often have long error chains, sometimes with details that seem at the surface totally unrelated to the required fix.
Our project limits the use of React Native primitives from our “smart” components, choosing instead to create our own set of primitives in a reusable UI framework.
/* @flow */
import Button as RNButton from 'react-native'
// Seal to avoid warning about inexact spread
type PropTypes = {|
    onPress: () => mixed,
    custom: boolean,
|}
// Trivialized component
const Button = (props: PropTypes) => <RNButton {...props } />
Under Flow, one must add every prop passed into
<Button>
to
PropTypes
, where instead we’d prefer to take all the props that
<RNButton>
does and add in our own custom one.
// Import props available for every React Native component!  :D
import Button as RNButton, { ButtonProps } from 'react-native'
interface PropTypes extends ButtonProps {
    custom: boolean,
}
const Button = (props: PropTypes) => <RNButton {...props } />
Much preferable (and accurate)!
Interfaces are one of the best parts about TypeScript!

#3—Refactoring HOCs

One benefit gained with TypeScript over Flow is the ability to use generic type arguments inside the body of your function. Not sure how advisable generally this is, but it’s a great feature to have when adding types for higher order components!
// TS allows the use of generics in the body of your HOC.  Yes!
const withAThing = <T>(config: Config<T>) => 
    (WrappedComponent: React.ComponentType<T>) => 
        (props: T) => <WrappedComponent {...props } />
However, in order to leverage this feature, we more-or-less needed to completely rewrite our type definitions for all of our higher order components. Ouch.
Needless to say we‘d prefer hooks!

After: 96% TypeScript Coverage

$ type-coverage -p tsconfig.json # ignored tests + storybook
68712 / 71471 96.13%
$ time tsc
tsc  29.82s user 1.76s system 166% cpu 18.955 total
Coverage increase of ~12%. Execution time more or less identical.
By far the most significant type coverage gaps exist in our Redux Saga code. This was true under Flow as well, but it seems switching to TypeScript hasn’t improved matters much, if any.

Summary

If you’re still using Flow with React Native, it’s never been easier to switch!
It took the equivalent of 3 engineers working 10 full working days to accomplish the conversion, including time taken for regression testing, code review, refactoring, and developing new usage patterns.
The majority of errors can be easily solved with a google search for the error message, while a few required time, consideration, and experience to be confident in the fix.
Though we discovered a few bugs (mostly related to incorrect third party API usage), this was a minor benefit to the effort. Gaining some additional coverage on our React code did allow for some dead code elimination.
Overall, we achieved a codebase better poised for ongoing future development both in community support and developer experience.

Written by Syz | Software Engineer
Published by HackerNoon on 2019/10/11