31 Aug 2017, 17:33

Moving an existing React/Redux codebase to Flowtype

Stumbling upon type errors in JavaScript can be a pain, especially because they appear at runtime, which makes it hard to track them down beforehand. While statically types languages have their types checked at compile time, JavaScript is still lacking such a feature.

In the JS world, there seem to exist two major players these days: TypeScript and FlowType. TypeScript appears to have a rather big community, which surely is not least because some well-known projects use it, most importantly the AngularJS framework. TypeScript has been developed by Microsoft, and has therefore some strong forces pushing it forward.

What both approaches have in common is that they transpile to plain JavaScript, and they are both supersets of JS. You can even write common JavaScript code in TypeScript as well as while using Flowtype, and it’ll just work, which is great.

This post is about Flowtype however, and the reason I use it instead of TypeScript is primarily because the integration into ReactJS just seems easier for me. For example, with TypeScript, I had to integrate an intermediate build step, which might be possible to accomplished easier, but I just did not get so far.

What Flowtype can do for you

Whether you use React or any other tool to build your web or mobile application, Flowtype gives you a compile time code analysis, which means: It checks your codebase for values that don’t match function signatures or interfaces, for incorrectly initialized variables and lots more. It integrates nicely with your IDE/editor, collects coverage, derives types where possible and tries to be as non-obtrusive as possible. If you do it right, it makes your code significantly more robust. Usually, you find tons of bugs that would have been discovered in runtime some day.

Certainly the Flowtype website is the best place to start if you don’t know anything about Flowtype. They have a lot of nice examples and detailed explanation of the tools Flowtype provides.

What is it all about?

Take this simple code sample, which takes a product, a discount and calculates the total:

const calculateTotal = (product, discount) => {
    return product.price - discount;
}

This snippet works fine, if you pass values that the function can handle. In this case,

  • product must have a numeric price property
  • discount needs to be a number

If you call it with invalid values, it will deliver a non-numeric value at runtime, like this:

calculateTotal({price: 10}, '10%');

=> NaN

This can lead to a broken application later on: If you rely on a valid total price, you’re in trouble.

If you annotate the function with some types, you can express explicitly what you expect:

// @flow

type Product = {
    price: number
};

const calculateTotal = (product: Product, discount: number): number => {
    return product.price - discount;
}

Please note the // @flow line, which makes Flowtype check the file. We changed the signature so that it expects a Product object, a number, and returns a number. When the annotated function is invoked using invalid values, Flowtype will throw an error:

calculateTotal(10, 10);

// Result:

8: calculateTotal(10, 10);
                  ^ number. This type is incompatible with the expected param type of
5: const calculateTotal = (product: Product, discount: number): number => {
                                    ^ object type

There are tons of other possibilities to describe your data, and they are all pretty well documented on their website.

Getting started

When you are a bit more familiar with Flowtype in theory, you might want to start to actually enrich your code with types, to make it more solid.

It starts with installing the tools and running flow init to create an initial .flowconfig file. This is where your flow configuration settings reside.

Essentially, all necessary steps should have been done, so it’s time to try your setup: Run flow check and be surprised that with a high chance, it won’t run. In fact, it’s high likely that you get hundreds or thousands of errors. This is because every project needs a tailored configuration file, which is the most annoying side of Flowtype.

Adjust settings according to you project setup

One of the most common problems migrating an existing project to Flowtype is that there are third-party modules throwing errors when they are handled by Flowtype. These packages must be identified and listed in the [ignore] section of your configuration file. Just adding node_modules would probably not work, because the parser would ignore also constraints that made it impossible to check some of your source files.

This process takes a while, but I you’ve done it one or two times, you get faster on identifying the problematic packages.

From time to time, you’ll find other pitfalls, like importing CSS files. To allow CSS imports when using Flowtype, the configuration has the be extended like this:

[...]

[options]
module.name_mapper='.*\(.scss\)' -> 'empty/object'

I’m pretty sure a lot of developers came across the page explaining it.

Digging through your codebase

When your project configuration has been adjusted correctly, you should get no errors on running flow check. On the other hand, your code did not improve, because you did not use any types so far.

The easiest way for me to begin migrating a project is to take a simple file, adjust it, typecheck it and continue until I get an error related to imported files. You’ll quickly get an idea which places you’ll be going to touch, and at the same time explore how big the impact is that Flowplay can have on your codebase in terms of making it more robust.

Example: Migrating some Redux code

A typical task is annotating Redux selectors, for example, take this simple selector:

const getProducts = state => state.products

To avoid passing an invalid state to the selector, it can be annotated:

// @flow
const getProducts = (state: ShopState): Product[] => state.products

We expect the state to be of some particular type, and we promise to return an array of Products. What’s clearly missing is the type definitions themselves:

// @flow
type Product = {
    price: number
}

type ShopState = {
    products: Product[]
}

It’s obvious that these type definitions are also useful in the reducers, because we can exactly describe how our state shall look like. So the next step will be to outsource the type definitions to a separate file typedefs.js, and adjust affected reducers, which later on could look like this:

// @flow
import type { ShopState } from './typedefs';

export const initialState: ShopState = {
    products: []
}

A nice aspect is that we can use this initialState in our tests, so we can be sure to use valid test data, for example, given we have this reducer:

// Reducer:
import type { ShopState } from './typedefs';

type Action = {
    type: string,
}

export const initialState: ShopState = {
    products: []
};

export default (state: ShopState = initialState, action: Action): ShopState => {
    switch (action.type) {
        case 'CLEAR_PRODUCTS': {
            return {...state, products: []})
        };
        default:
            return state;
    }
};
// Test:
import reducer, { initialState } from './reducers';

describe('reducer', () => {
    it('should clear products ', () => {
        expect(reducer({}, { type: 'CLEAR_PRODUCTS' })).toEqual({
            ...initialState,
            products: []
        });
    });
});

This test will fail, because the reducer’s initial state is invalid.

React components

Functional stateless components can be annotated as well as class based components, while the syntax needed for class based components looks somehow weird at first view:

// @flow
type Props = {
    width: number,
    height: number
}

// Functional stateless component
const FunctionalPage = (props: Props) => <div width={props.width} height={props.height} />

// Class based component
class ClassBasedPage extends React.Component<Props> {
    render() {
        return <div width={this.props.width} height={this.props.height} />
    }
}

Common Pitfalls

Too big steps

One of the biggest mistakes is to migrate too much code at once. There might be dependencies which lead to a big refactoring, and it can be hard to keep track of all the open ends. On the other hand though, the migration process reveals all the potential bugs that you’re avoiding now, using Flowtype. Anyway, my advice would be to take small steps, commit often (you can squash later on), and extend your test suite.

Underestimate effort

Integrating Flowtype can take time, especially on React/Redux based applications. Don’t underestimate the effort you’ll have to put in, even if the benefit will be worth it. And be sure that it’s the right time, because if you’re working in a team, you will pretty sure run into lots of merge conflicts, if your changes are too extensive.

Résumé

Learning Flowtype tools and syntax is not too hard - doing the initial configuration is a tricky thing, and figuring out your data structures takes time. But this is not Flowtype related, but just type design. Don’t give up if you’re running into masses of initial errors, this is common, and most problems can be solved easily.

I am using Flowtype in a couple of projects now, and since a while, I’m not setting up new projects without it. It adds a huge value to your codebase, improves testability, and just makes it less error-prone.

Read more