Hi, I’m Arthur and I’m a front-end developer. In this article I want to share how we at Exness transferred the code base of our service from Flow.js to Typescript.
I’ll guide you through our development process, but we’ll also touch upon the technical component as well.
Though we did not invent a new approach or programming language, it was a rocky journey to achieve the set goal. Our case is a shining example of an abstract task “Get from point A to point B. High uncertainty, the deadline set. Let’s go.”
We’ll have a look at the task scheduling process and the sequence of steps; tackle the difficulties of distributing jobs between developers and finding a way to get around them; test the outcome and consider what we need to go live.
Background
Let’s think for a moment about how and why developers started transitioning to TS. Javascript is a dynamically typed language, meaning you do not need to explicitly specify the type of variables. This is great because it’s fast, and it eliminates any extra writing. After a while, however, developers realized that they wanted to know exactly the types of incoming parameters, so they began to add many checks of the incoming values. It was painful in terms of development, testing, and modifications.
That was when the types indicated in function annotations appeared. These helped the developer see what was transmitted correctly or incorrectly at the build stage. However, this was not very convenient, the functionality was limited, and did not allow for writing complex structures.
It was followed by Facebook’s flow.js development, becoming a big step toward “correct” typing. It offered online checking, the capability to reference types and their properties, and support in IDE. However, it’s a static checker, meaning it analyzes the code for adequate type usage by developers. In practice, though, some types are incorrectly inferred and need to be specified explicitly, which generates excessive text. Inferring types from functions and methods do not always work well. It’s a peculiar syntax. The technology no longer shows explosive growth.
Meanwhile, enter Microsoft’s Typescript. It’s already a fully-featured code compiler and a Javascript add-on that uses a strict mode of type-checking. This is actually becoming mainstream: support and development by Microsoft, and rich compiler capabilities for intricate and complex types, while some fixers even manage to do list sorting through typing. High level of IDE integration. Type inference is so good, it is now sufficient to describe them in several “basic” points so that further down the chain, the type is inferred in both functions and classes. The usual Java/C# syntax.
Convenience, power, mainstream, low project entry threshold for new developers, common usage, really good type protection against developer error — all these reasons led us to choose Typescript as soon as we had sufficient resources available.
Starting Line
Meet our project. It’s important that I introduce you to our starting point to provide perspective, help compare our processes with yours, and just improve your general knowledge. We have a typical React-Redux-Saga app; however, it was compiled independently rather than being assembled using a create-react-app utility. We use webpack for assembly tasks, babel for transpilation, and eslint as a linter. We have react-hook forms, the usual components from simple to complex containers and server-side rendering. We use Ramda.js and XState.js as unique libraries. We have a set of our common components written in JS+Flow that we import separately. The backend uses the express.js framework. So it’s pretty typical. I am positive that many projects written in React have a similar structure. And the hero of the day is Flow.js, of course.
Step 1: Setting the Stage
First off, we had to configure webpack.config, eslint, babel for the TS code. To do that, I took a peek at a neighboring service. That project was quite similar to our own, with a few minor differences. It’s one thing when you’ve been working with TS for a few years, and quite another when you have to configure it. It’s a whole different ball game.
So, take the tsconfig template and make sure you spell out the path block so that the IDE could use aliases for imports:
{
"compilerOptions": {
"allowJs": false,
"allowSyntheticDefaultImports": true,
"baseUrl": "src",
"paths": {
"types/*": [
"../types/*"
],
"typings/*": [
"./typings/*"
],
"components/*": [
"./components/*"
]
},
"otherProps": {}
}
}
Configure ts files processing in .webpack.config by adding the ‘@babel/preset-typescript’ plugin to the rules for babel-loader.
On the list of extensions in babel.config, add ts/tsx files with the extensions: [‘.js’, ‘.jsx’, ‘.ts’, ‘.tsx’, ‘.css’]. Add the .ts/.tsx files processing to eslint.config.
For jest.config.js, we had to indicate a handler for the .ts files and to specify the extensions
{
globals: {
'ts-jest': {
tsconfig: 'tsconfig.json',
},
},
testMatch: ['**/__tests__/**/*test.js', '**/__tests__/**/*test.ts', ],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', ],
}
Even for .flowconfig, we had to specify the extensions for the ts files so that it could “bypass” them correctly.
[options]
module.file_ext=.css
module.file_ext=.js
module.file_ext=.ts
module.file_ext=.tsx
module.file_ext=.json
Issues:
With no prior experience customizing these things and having the code and documentation for multiple services on our hands, it was difficult to understand what exactly had to be specified in our case. Despite the assurances in the documentation, the linter for the ts files did not work for both the js and ts files, so we made a separate block to process the ts files with rules to be added later during the refactoring process.
Solutions:
Google and Yandex searches helped a lot. In general, the developer’s work involves finding a lot of information, whether at https://developer.mozilla.org, stackoverflow, or https://www.typescriptlang.org. The “neighboring” services have been of great help, too: I learned a few new tricks on them during the first step. You cannot just take a config file and make a copy. In the end, having examined three of our services, I tried a few approaches and settings, generated a bunch of bugs, and set it all up correctly after several approaches. Even for similar services designed for the same purpose, the code bases are completely different.
In the meantime, my colleague was finishing his other tasks and preparing to join the process.
By the end of the first step, we had a branch with basic settings for assembling and checking the ts/tsx files. We could now start the transfer of the components.
Looking back at the work done, I can see that the transition from Step 1 to Step 2 could have been somewhat different. I’ll delve deeper into that in the Analysis section below. But now…
Step 2: Refactoring Components
At this point we had two developers. We had to work on the common code base while the task distribution lines blurred and code overlaps became inevitable. We had to decide how we would work together. To do this, we recalled that our task was to transfer components to TS in such a way that the JS-powered code would remain functional. It was primarily due to the limited time we had; we couldn’t afford to transfer everything to TS, nor was that necessary. But we had to work out an approach to the files: whether we should change the existing files or create new files next to them, whether we should leave the components’ structure untouched, and how we should determine the sequence of the changes.
In the end, we decided to do the following:
1. We did not change the existing js utility files and redux-store functionality. Instead, we copied the files and added suffixes to the names so that we could easily change the imports later and see what had or had not already been transferred. So the JS code used the same files as before.
2. For components and modules imported from our JS libraries, we created typings and added them to our service, rather than changing the shared libraries.
3. We left the file structure inside the components untouched as it was quite successful:
Component
| Component.container.tsx
| Component.css
| Component.type.ts
| Component.tsx
| Index.ts
4. We would change the core features of the app and utilities for SSR as a last resort only.
5. All development was done in a single feature branch we both added commits to.
6. Regular live sync regarding who worked on which component/file.
We split the components and launched the process.
We were lucky, I believe, that we had flow.js, and not just JS, because we’ve already seen what types were used. We only had to modify the syntax and check if we had accounted for everything. A few times we saw props that were redundant and were “communicated,” although this was not required for the component. Or vice versa, when one type was specified, but entirely different parameters were passed, and it all worked by pure chance because the parameter names matched.
Once we agreed on how to name the files, where to add types, and how particular flow.js syntax is transferred to TS, we had very meticulous and monotonous work ahead of us. To tell you the truth, whoever designed the service prior to us had done an almost perfect job. All components and utilities, store, actions and selectors — everything was controlled by particular schemes; thus, the order was maintained. It made our task easier in many ways.
The component transfer process looked as follows:
- We check the component types and overwrite it using the new syntax, changing the file extensions to .ts.
- In the component, we change the extension to .tsx, remove all the excessive // @flow annotations, and change exports to TS files.
- If utilities are used anywhere, we also make adjustments there. A kind of an in-depth tree traversal.
- We manually check manually that all the required props ar passing and nothing odd is being communicated, then clean it up and put it in order.
- Then, we run the linter and see if the system has any complaints.
- After that, we do the commit.
- We take the next component/file and move on to Step 1.
It was meticulous, but predictable work. Sometimes we just changed the syntax using the cmd+shift+R replacement in Intellij Idea in the bunch of files. In other cases, we had to work as a compiler and closely monitor what types were in use and where.
We spent most of the time refactoring the components. As I mentioned earlier, we changed the core of our app first: all UI components, actions and selectors, and most of the utilities used by these components and selectors. At this step, we didn’t change the JS core of the service as it wasn’t required.
Issues:
When multiple developers work on a common part of the code, conflicts in the code are inevitable.
Issues with flow.js and how it works with TS. Although the flow documentation said that only files with the // @flow annotation were processed, if the JS file in the import chain used a TS file inside, flow also looked at the TS file and was totally clueless as to expressions like ComponentProps[‘property’]. .flowignore did not help, either.
The mere human factor when you forget to do pull before you commit to the common branch, and new changes were not communicated causing the list of commits in Git to become horrendous and branching.
Solutions:
The conflicts appearing in the code were the main inconvenience. Rollback of changes, the failed merge and rebase could fix the situation. Regular discussions and syncs made it easy to fix the conflicts. We also split the refactoring areas well, for the most part.
The situation was more complicated with flow.js when it did not accept the .ts files. Whenever possible, we edited the files along the chain of imports; in other cases, we sent the types to global typings. At some point, we just disabled checking the flow.js typings because we stopped changing the js files.
One sprint into our work, I had to switch to other product tasks and bug fixes. My colleague continued to transfer the components. Then, at some point, he also switched to a product task: it was already based on our global refactoring. We’ll know the outcome when we do the retrospective session.
The “beauty” of switching to other tasks was that before merging refactoring with the master we had to transfer new solutions to TS as well.
Step 3: Testing
As Martin Fowler reminds us in his book Refactoring, before refactoring, we need to make sure that the tests exist and the tests pass.
Testing is an extremely important process in any development task, and its outcome will determine the number of bugs in the product.
As part of our task, we planned to run existing regression tests and check our pages manually.
Since this refactoring did not affect the business logic, the automated green tests were the first sign that everything was fine; it was to be expected. Checking the main flows manually was an additional safety measure before the release.
Thanks to a large number of autotests available, we managed to find and fix some nasty bugs at pre-launch. We then had a few more runs before manual testing.
Typically, when a developer’s task moves to the “In Testing” column on the kanban board, the developer can breathe a sigh of relief because they have completed all the required tasks and the process has passed on to the tester.
This time, however, we couldn’t do this xD as the testing took some time. In addition to our already huge task, the QA team already had their hands full with others. The testing process took several days.
Besides, we had to quickly refactor the code that we added to the master branch while solving other tasks, using the designed process.
Our developer responsibilities were not limited to the refactoring, though, so we took on other projects for the testing period.
Final Step: Release Day
Release Day. To tell you the truth, it was an extremely emotional day. We certainly didn’t hit the Deploy button together, but we had our fingers crossed xD
We sent feature branch to the master the regular way, re-ran all autotests, assembled a version, and hit Deploy. The QA specialist spent much longer than usual watching the monitors. After that, the release was pronounced a success.
Woo-hoo, we did it!
Another thing my colleague and I could be proud of was that no bugs were identified in the product as a result of the refactoring. I consider it a great success for all of us, the result of painstaking work by both the developers and the QA team.
Analysis
In this section, we’ll look at the outcomes and “leftovers,” at what could have been done differently and why.
— What were the outcomes?
By the end of the refactoring process, we had all the UI components, the entire store except some saga, some of the service’s core features (where necessary) transferred to TS; we also transferred the code from ramda to native TS, whenever justified. We formed a solid foundation for future development on TS.
— What did we not do and what was left?
We didn’t touch the core libraries that had been independent, we didn’t transfer a big component containing complex business logic and using xstate.js to TS, but we plan on refactoring it separately. We didn’t transfer our ui-libraries that were imported as separate packages — it’s an entirely different activity. We barely changed the jest tests, and the .ts features and utilities connected well.
Looking back, I’d like to highlight a couple of things: something I would do differently should I run such a refactoring process again.
Although we had a relatively long time period on our hands and we could afford to transfer as many components as possible at once, I would still break up the task differently. On another occasion, I would add configs, assemblers, and linters to Step 1, send it all to production, and finally proceed with the refactoring components. This would enable the development of new features on TS and reduce the emotional burden of lengthy work on the task.
Speaking of another development aspect, I think it was inconvenient to develop together in a single branch using commits rather than the merge request in the common feature branch. This way, we could have avoided forgetting things and having to constantly check who did what. In the next refactoring process we worked on together, we used the merge request and had fewer issues in terms of syncing. Anyway, it’s purely a matter of agreement between developers; there are always a few different ways to resolve issues xD
Takeaways
In conclusion, I’d like to point out that transferring an existing code to Typescript is a hard and time-consuming task. We had some attempts in our project, too, but the developer worked alone and wanted to transfer everything at once, as we did. I think nothing came of it because the developer tried to go all-in, rather than break the task down into subtasks. With two developers, it was much easier in terms of both performance and peer communication.
You need to explicitly allocate the time and developer + tester resource for this activity; you won’t be able to just do it on the fly — this must be taken into account. But it’s totally worth it. Subsequent development becomes faster, clearer, and more enjoyable.
Now, looking back at all this activity after a while, I can see that we had our share of luck, too. Firstly, we had flow.js, rather than just js. Unlike our colleagues, we did not have to track which parameters and types were used everywhere. Secondly, we were able to review the successful projects of other services we had developed. Otherwise, we would’ve spent to the tune of an extra week on the development tasks.
Thirdly, our Team Lead was totally onboard in that we needed refactoring, he supported us, and allotted enough time, given that we had exceeded the implementation timeframe.
Hopefully, my experience will come in handy for those who still have this journey ahead of them. If you need to do this in the near future, I hope the path I have traveled will make the road easier for you.