How to Migrate an App from Flow to TypeScript

Hi! My name is Oleg, I work as a frontend team lead in the application development team — Gazprombank's retail credit conveyor. I prepared a guide on how to switch from FlowJS to TypeScript.

What were the problems with Flow?

Let me note right away that we are talking about Flow version 0.78 – perhaps much of what I will describe below has been resolved.

The problem of loss of closure types. Let's assume we have some function. We have typed it, written that it returns a number (i.e. some timestamp) with a cutoff of one hour. Then, if we return it from some external function and write it into a variable, the value of this variable will have the any type.

This was solved with the help of “crutches”: in the first case, we could prototype the variable itself (in which we save the result) as number, and it became number. In the second case, we could prototype the closure, so we duplicated the typing of the function itself, but at the same time we received the resulting type when calling this function.

Lack of coverage in NPM. External packages rarely contain Flow declaration files, which is why we have to use the flow-typed type autogenerator. As a result, we again get any types and, in fact, no typing.

The problem of mixed types. Probably, only the lazy have not written about this on specialized forums. The most common way to get this problem is to iterate over an object by value.

// Даже протипизировав все значения ключей объекта как number
let numObject: { [k: string]: number };

// Object.values приведет их к mixed и просуммировать значения не получится без описания guard'а
Object.values(numObject).reduce((acc, cur) => acc + cur, 0);

Loss of secure check in Callback. What's the point: if we first did an if check for an optional type or null, and then later in the code we tried to access the same variable inside a callback (for example, map or filter), then we again had to check for undefined or null.

let someData: number | null;

if (!someData) {
  return;
}

sampleArray.map((el: number): number => {
  // flow снова требует проверку
  if (someData) {
    return el + someData;
  }
  // также придется описать код, который никогда не исполнится, так как коллбэк требует возвращения значения
  return el;
});

// вне коллбэка проверка, указанная выше сохраняется
const newVal = someData + 1;

The transition to the new version of flow resulted in more than three thousand typing errors. Their correction was proportionate in terms of labor intensity to the transition to new methods of static typing.

Why TypeScript?

The main thing is that already on version 3.8, which was available at that time, it solved the problems described above.

In addition, we have auto generation of types for DTO. We use Svagger on the backend in the project, and with the help of the json-schema-to-typescript library, we automated the process of describing DTO data types, speeding up development several times. The output in this case is a TS file with a set of interfaces used on the backend, which can be updated at any time.

TypeScript has ENUM — a very powerful tool. It is both data and types at the same time. Thanks to ENUM, you can very quickly and conveniently define models for Form Values ​​in a form and for Validation Results — literally in a couple of lines.

An example of how to quickly describe the types of form fields and the validation results of this form:

enum ECreditFormString {
  FirstName="firstName",
  LastName="lastName",
  // ... еще поля
}

enum ECreditFormNumber {
  Age="age",
  // ... еще поля
}

type TCreditForm = {
  [P in ECreditFormString]: string;
} & {
  [P in ECreditFormNumber]: number;
};

type TCreditFormValidationResults = {
  [P in keyof TCreditForm]?: string | null;
};

Finally, we started using advanced TS constructs that allow us to quickly describe complex correct typing, avoiding the “cheats” that were in Flow. For example, a quick enumeration of a type into another using the in keyof construct from the example above.

How to implement transition in TypeScript?

Step 1. Setting up the environment. You need to install TypeScript itself and the type libraries used in the projects, describe TS Config and configure ESLint. To configure ESLint, you can simply describe the overwrite array for JS and JSX files — set the appropriate parses, rules and settings for them, then do the same for TS and TSX files.

Step 2: Configure Jest. Hopefully many of you use unit tests. If so, you've probably encountered the problem of mocking functions when one file exports multiple functions. Because of ES's module system, mocks are not attached to the original functions in your code, but to their copies in the test file, resulting in counters not working for the original code, and mocks, by their nature, not working. TypeScript's module system solves this problem by default, so writing unit tests in .ts files immediately becomes more convenient.

To configure jest-config, you need to supplement the fileExtensions module with new file solutions, .ts and .tsx, add the ts-jest library for .ts files, and don't forget to add the .ts, .tsx extensions to transformPatterns. This way, Jest will work correctly for both .js and .ts files.

Step 3. The most important thing: translating the code and changing the file extension. Since the process is spread out over time, we need to ensure the possibility of a gradual transition, that is, to make sure that typing is preserved at every moment in time – at every commit. If we simply translate JS files into TypeScript, and TypeScript methods into JS, we will receive data with the any type and we will lose typing

At every point in time, on every commit, our typing in the application should be no worse than at the point in time after the commit.

It is also necessary to ensure parallel work here, so that as many developers as possible can translate the application at the same time.

Analysis of the process of transferring modules

Let's assume we have two modules: Customer and Product. The Product module will be the starting point to start translating from FlowJS to TS.

The input file in it is the Products component (products.jsx).

Here, notice that Products contains a ProductList component, which uses the Product component (product.tsx), located in the components directory, and is defined in TypeScript.

We are trying to import it into JavaScript. In this case, it is imported via an index file — but our index is also TS. Accordingly, this Product has the any type for Flow. To avoid this, we need to create a new JavaScript file in the components, name it index.js, similar to the imported file, and add the Flow extension (this way we will get a declarative file index.js.flow).

Here we describe that this is a Flow file.

In the file itself, we need to add a type for the Product component and its props so that it is recognized in our .js file. To do this, we use the declare keyword, write that this is var Product and instead of implementation, we specify only the type. In this case, it will be the ComponentType of the React library (you can also set the FC or Component type). Now in the ProductList component, the Product component will be prototyped as a react component.

It is important to note here that the index.js.flow file we created is not an executable file, the executable file is still index.ts.

That is, import { Product } takes the executable data not from index.js.flow, but from index.ts. Then Flow finds a similar name — index — with the extension .js.flow and takes the types. Thus, the Product component becomes typed for the product-list.jsx file, although its executable code is in the .tsx file.

Let's look at another example. Our Product component uses the ProductActionBar component from the product-action-bar.jsx file. That is, now, on the contrary, we import data from a file described in Flow into a file described in TS. Without additional modification, all data from this file will be described as any from the TS point of view.

To avoid this problem, we add a new TypeScript file here in components, call it product-action-bar, and put the extension .d.ts — declarative in the understanding of TypeScript.

Now, when we import the ProductActionBar component into the product.ts file from product-action-bar.jsx, we, by analogy with the previous example, also import executable files, but from .jsx, and TypeScript will search the directory for a similar file name, but with the .d.ts extension, and substitute types for TypeScript. Thus, ProductActionBar will become typed for the executable file product-action-bar.jsx.

Developers often have a question, especially when they use an IDE: “What should I import here: .jsx or .d.ts?” In fact, it makes no difference if the extension is configured correctly in Babel: the compiler itself will understand what to take from which file. If we have a file extension .js, .jsx, .ts, .tsx, then it is always an executable file, if we have an extension .d.ts or .js.flow – it is always a declarative file.

Now, let's say customers.tsx uses the Products component. The component from the products module is naturally imported from the module index – products/index.js.

Since customer.tsx is written in TS, we need to add a declaration for the products module index – products/index.d.ts. Next, by analogy with the previous examples, we need to describe the declaration for the Products component in the index.d.ts file.

Now we can always import the Products component into both .js and .ts files without losing the data type.

Questions and difficulties that may arise

Below I will answer a couple of questions that often arise when migrating applications to Type Script.

Are .js.flow and .d.ts files temporary? At what stage of the transition can they be deleted?

d.ts and .js.flow are declarative files that are needed to save data types. When we move from Flow to TypeScript, at some point there will be modules or pages that are already fully translated. However, at this stage, we cannot delete .js.flow files, because we do not yet know in which module we may need a type for Flow. As soon as there are no executable JS files left, we will delete all .js.flow, because there is no more Flow in the code.

The story is exactly the same with d.ts. At the moment when the application consists entirely of .ts and .tsx files, we delete all .d.ts and all .js.flow. The only exception is global.d.ts (a file with global types that do not require importing) will remain.

Let's say there is a project 95% in Flow and the task is to rewrite it in TS. It consists of constants, modules, pages, components, and so on. Where is the best place to start?

As practice shows, the best approach is to first rewrite modules that do not import anything into themselves and are sources themselves. Then — tackle pages, because it is difficult to consider a separate module outside their context. In parallel, we rewrite non-independent modules. And, finally, all sorts of constants and the rest.

Components should be defined in three categories: page parts, module parts, used throughout the application. The first two categories are transferred to TS along with their module or page. The third category is generally better to be moved to a separate private npm library.

So, we've covered the process of migrating an application from FlowType to TypeScript. If you have any questions or have already made the transition and encountered difficulties in the process, write in the comments, and we'll figure it out together!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *