How to gradually migrate a large React codebase to TypeScript

How to gradually migrate a large React codebase to TypeScript

It's been a few weeks since me and my team have been discussing about migrating a large React codebase that we are developing written entirely with JavaScript for TypeScript because of the numerous benefits that its adoption would bring to our project, but due to the amount of files that our project already has, it would be impossible to migrate everything at once without impacting the deliveries of new features, so it was necessary to find a strategy to do this gradually. If you find yourself in a situation similar to this and are not sure on how to proceed I will show you the strategy adopted by me and my team, and believe me, the solution is super simple!

Tools used in our project

Over the past months we have been working on building a large Dashboard for a client and among other tools we have been using:

  • Eslint + Prettier :  I consider using a linter impressible in any JavaScript project and the combination with Prettier is the perfect wedding for your projects, as they help maintain a consistency between the code style of all developers If you don't use these tools in your project I strongly recommend that you consider including them as soon as possible. We use the airbnb pattern with some rules adapted to some conventions we use.

  • babel-plugin-root-import:   If you have at some point already suffered from imports like this import Button from ".. /.. /.. /.. /.. /components/Button;" in a React application you probably already have come across some library that helps to solve this problem by setting a starting point for imports by turning imports into something like: import Button from "~/components/Button";

  • eslint-plugin-import-helpers : This fantastic lib organizes all the imports of the application according to the user-defined configuration. Being able to separate files by the names from their folders in alphabetical order and automatically skip lines between categories, maintaining consistency throughout the project.

How to migrate the project

Thanks to the TypeScript compiler it is possible to keep .js and .ts files simultaneously in the project as needed, but the settings of the tools mentioned above are specific to JS and therefore it would be necessary to change them to suit the .ts files, but we didn't want to lose their support in the old files.

To solve this we created a new project with create-react-app using the TypeScript template and configured all these tools in this new project to suit the TypeScript files (there are several tutorials on the internet teaching how to do this).

Once that was done, we installed the TypeScript-related libraries that didn't exist in the original project one by one and copied all the ESlint configuration made for the TypeScript project and added within the tag overrides in the .eslintrc.js file of our project. In the file below you can see how the full configuration turned out:

module.exports = {
  env: {
    es6: true,
    jest: true,
    browser: true,
  },
  extends: ['airbnb', 'prettier', 'prettier/react'],
  globals: {
    Atomics: 'readonly',
    SharedArrayBuffer: 'readonly',
    __DEV__: true,
  },
  parser: 'babel-eslint',
  parserOptions: {
    ecmaFeatures: {
      jsx: true,
    },
    ecmaVersion: 2018,
    sourceType: 'module',
  },
  plugins: [
    'react',
    'jsx-a11y',
    'import',
    'import-helpers',
    'react-hooks',
    'prettier',
  ],
  rules: {
    'prettier/prettier': 'error',
    'react/jsx-filename-extension': ['error', { extensions: ['.js', '.jsx'] }],
    'import/prefer-default-export': 'off',
    'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
    'react/jsx-one-expression-per-line': 'off',
    'global-require': 'off',
    'react-native/no-raw-text': 'off',
    'no-param-reassign': 'off',
    'no-underscore-dangle': 'off',
    camelcase: 'off',
    'no-console': 'off',
    'react/jsx-props-no-spreading': 'off',
    'react-hooks/rules-of-hooks': 'error',
    'react-hooks/exhaustive-deps': 'error',
    'no-plusplus': ['error', { allowForLoopAfterthoughts: true }],
    'react/forbid-prop-types': 0,
    'import-helpers/order-imports': [
      'warn',
      {
        newlinesBetween: 'always', // new line between groups
        groups: [
          '/^react$/',
          'module',
          '/^@material-ui/core/',
          '/^@material-ui/icons/',
          '/^@material-ui/pickers/',
          '/^@devexpress/',
          '/^~/contexts/',
          '/^~/hooks/',
          '/^~/components/',
          '/^~/',
          ['parent', 'sibling', 'index'],
          '/styles/',
        ],
        alphabetize: { order: 'asc', ignoreCase: true },
      },
    ],
  },
  settings: {
    'import/resolver': {
      'babel-plugin-root-import': {
        rootPathSuffix: 'src',
      },
    },
  },
  //Configurations for TSX files
  overrides: [
    {
      files: ['**/*.ts', '**/*.tsx'],
      extends: [
        'plugin:react/recommended',
        'airbnb',
        'plugin:@typescript-eslint/recommended',
        'prettier/@typescript-eslint',
        'plugin:prettier/recommended',
      ],
      globals: {
        Atomics: 'readonly',
        SharedArrayBuffer: 'readonly',
      },
      parser: '@typescript-eslint/parser',
      parserOptions: {
        ecmaFeatures: {
          jsx: true,
        },
        ecmaVersion: 2018,
        sourceType: 'module',
      },
      plugins: [
        'react',
        'react-hooks',
        'import-helpers',
        '@typescript-eslint',
        'prettier',
      ],
      rules: {
        'prettier/prettier': 'error',
        'react-hooks/rules-of-hooks': 'error',
        'react-hooks/exhaustive-deps': 'warn',
        'react/jsx-filename-extension': [1, { extensions: ['.tsx'] }],
        'react/jsx-one-expression-per-line': 'off',
        'react/jsx-props-no-spreading': 'off',
        'react/jsx-curly-newline': 'off',
        'react/prop-types': 'off',
        'react/jsx-wrap-multilines': 'off',
        'import/prefer-default-export': 'off',
        'import/extensions': [
          'error',
          'ignorePackages',
          {
            ts: 'never',
            tsx: 'never',
          },
        ],
        '@typescript-eslint/explicit-function-return-type': [
          'error',
          {
            allowExpressions: true,
          },
        ],
        'import-helpers/order-imports': [
          'warn',
          {
            newlinesBetween: 'always', // new line between groups
            groups: [
              '/^react$/',
              'module',
              '/^@material-ui/',
              '/^@devexpress/',
              '/^components/',
              '/^routes/',
              '/^services/',
              '/^utils/',
              '/^page/',
              '/^contexts/',
              '/^hooks/',
              '/^layouts/',
              ['parent', 'sibling', 'index'],
              '/styles/',
            ],
            alphabetize: { order: 'asc', ignoreCase: true },
          },
        ],
      },
      settings: {
        'import/resolver': {
          typescript: {},
        },
      },
    },
  ],
};

Note in the file above that we have completely different settings within the overrides tag and that they are only applied to the .ts and .tsx files!

Once this is done, all you need to do is to change the file extension when you are ready to migrate it. ESlint will point you some errors and you can fix them one by one. If you are starting with TypeScript now don't be afraid, you may loose a little bit of performance on the first days, but I guarantee that once you get used to it your productivity will increase a lot!

Some points that deserve attention

  • TypeScript by default can already root-import files by simply adding this to tsconfig.json: "baseUrl": "./src" within compilerOptions (See that we are using different import methods for JS and TS files).

  • In order for VS Code to understand where to fetch the files when using root-imports in JavaScript files you need to have the jsconfig.json file, since TypeScript projects need the TypeScript compiler to be configured with the tsconfig.json file, but VS Code only accepts one of the two files. Because of this we lost the possibility of jumping straight to the files by clicking on them in the .js files, but this was a very valid exchange in our opinion.

Conclusion

This was the strategy adopted by me and my team to solve this problem and is working super well because we have been able to migrate our application without impacting on new deliveries.

At the moment I'm writing this article we have approximately 50% of our code migrated and the process has been extremely satisfactory, because we have been implementing new features directly in TypeScript and whenever it is necessary to change some .js file we take the opportunity to migrate it to TypeScript as well.

I hope this story can help you convince your team that yes, it is possible to migrate large codebase painlessly!

I wish you much success in your projects. Thank you for reading!


If I helped you with this post, consider to buy me a coffee on Ko-Fi 🙂

ko-fi