How to incrementally implement Typescript in your NodeJS project

Often developers start building a project in Javascript without caring much about type-safety during the initial phase. However as things start getting complicated on addition of new functionalities, it becomes a riskier business to scale. There would be multiple developers working on different functionalities and having no kind of type-safety poses a high risk of scaling the code.

I ran into a similar issue when one of our batch script in NodeJS had grown exponentially and there was no kind of type safety check. We had recently added the implementation of a new billing module in this script and I thought this was the best time to evaluate how Typescript would add more value by integrating in this new billing module.

I was not interested in a complete big bang implementation of Typescript and wanted to evaluate the benefits Tyepscript would provide for my batch script in NodeJS. So I picked up the most critical part of the batch script and started to migrate it to Typescript. Lets get down to the steps involved in incrementally migrating to Typescript.

  1. Install the required modules.

    npm i typescript ts-jest --save-dev
  2. Create the TS config file by running. This should create the tsconfig.json file which holds the config parameters required for the typescript compiler to run.

    tsc --init

  3. Lets first set the basic set of config parameters. Since my use case was for a batch job in NodeJS, I had to set the below set of params :-

    /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
    "target": "esnext",
    
    /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
    "module": "commonjs",      
    
    /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
    "moduleResolution": "node",
    
    /* Redirect output structure to the directory. */
    "outDir": "./build",                        

    Most of the parameters are self-explanatory and it also generates a simple set of comments for each of the attributes. One of the key attribute here is the outDir attribute which has been set to /build directory. This would allow the TS compiler to generate the compiled files in /build directory.

  4. Make sure to update the npm scripts to point to the correct dir. For e.g.

    "start" : "node index.js" // Old Code
    "start" : "node build/index.js" // New TS Code
  5. Now, the first thing which impressed me was that Typescript was able to check even the Javascript files and for this I just had to change the below parameter in the config file :-

    "allowJs": true, /* Allow javascript files to be compiled. */
    "checkJs": false, /* Report errors in .js files. */

    By setting allowJs to true would allow Typescript to even compile JS file. This is very important when you want to incrementally implement TS in your project.

  6. Lets see what TS would build with these set of configurations by adding an extra step in our npm scripts for build.

    "build" : "tsc"

  7. You should see the files in the build directory which at this moment would the same JS files as nothing has been yet converted to TS. But along with this, you would also see a list of errors for your JS files. Now some of them would not be applicable but I was able to see issues where a function just had say 3 parameters and I was passing more than 3 parameters. I also saw some issues with the way I was using momentjs. So my first step was to clean the legit issues reported by TSC for my JS files.
  8. I was now ready to move my selected files to Typescript. I started by renaming the JS file to .ts extension followed by running npm run build. At this moment I had to switch off the reporting of JS errors as I had already cleaned up the legit ones and wanted to focus on the TS ones now.

    I went through the documentation to know the basic stuffs of TS.

  9. By this time, I had created the required interfaces and types needed for my file. I wanted to keep all the interfaces related to the module I was migrating in a single file and then import it. I created a file <module-name>.interface.ts where I had all the interfaces. You will have to use import to import the required interfaces.

    By this time, I again discovered some more issues in my code which I had overlooked. There were issues where my array were supposed to hold an array of objects whereas I was pushing the results of filter function directly to the array thereby creating an array inside array. I was then using lodash’s flatten method while passing the data to other functions. I had also identified some places in the code where there were chances of null pointer exceptions.

    There were some pieces of the code which was fetching data from a MySql table in the DB and that was being directly passed to the functions. There was no way where Typescript would be able to identify the types and determine type-safety. In order to fix this, I created models where on passing certain params, it would return the actual object. I created interfaces for the input params and for the return type of the models. Now the code flow was like after fetching data from mysql tables, it created objects from this model which was then passed on to the required functions. This allowed type-safety across the flow which gave me far more confidence in my code.

  10. During all this cleanup, I did face certain issues and was not quite clear as to what might be the best approach.

    • The way type was defined with destructured params was adding redundant code. So I removed the destructuring of params in the function call and instead getting it in a single attribute and then setting its type.

      const function1 = ({param1, param2, param3}) => () // Old Code
      
      // Setting type for destructure params - Typescript
      const function1 = ({param1, param2, param3}, {
      param1: number
      param2: string
      }) => ()
      
      const function1 = (params: ParamType) => {
          const {param1, param2} = params
      }
    • There were certain portions in the code where an attribute is set from one or the other param and either of them should have a value. For e.g in the below example, we are expecting an object where either it should have a zipcode or the name of the place.

      type PlaceInfo = {
          zipCode: number,
          name?: string
      } | {
          zipCode?: never,
          name: string
      }
      const getWeather = (info: PlaceInfo): string => {
       const {zipCode, name} = info
      // Get weather data by name or zipcode
      }
    • At some places, there was a function which was doing some validations before accessing the attributes, but Typescript was complaining that it can still be undefined. In those case, I had to use the non-null assertion operator

      Check out this example to know more about this.

  11. Typescript does not have an official guideline for naming conventions but I felt naming my interfaces by starting with "I" was helpful to eliminate the clashing of same variable name and interface names. ESLint will report it as an issue, but I disabled that rule in my config file as this consistent naming pattern was helpful in my case.
  12. Some utility types were super helpful in my project. Do check them out to see whether you can use them in your code.

    https://www.typescriptlang.org/docs/handbook/utility-types.html

  13. I had finally cleaned up all the TS errors and now I was ready to check whether all my unit tests , commit hooks & code coverage were working fine.

    For my Jest unit tests to run, I had to use ts-jest to transform my code. I just had to add the below config in my Jest :-

    transform: {
        '^.+\\.tsx?$': 'ts-jest'
    }

    Also, make sure the code coverage is set to cover even the TS files

    collectCoverageFrom: ['**/src/**/*.{js,ts}']
  14. If you would like to import a TS file in a JS file, make sure you require the default one

    const module1 = require('module1').default

    If you are using other node modules with types in a TS file, make sure you use import to use those functions to get proper typings. For e.g. if you have installed lodash types and want to use their groupBy & cloneDeep function in a TS file :-

    import {groupBy, cloneDeep} from 'lodash' 

    If you use the below syntax, you wont get proper typings but the functionality will still work :-

    const groupBy = require('lodash/groupBy')
  15. Make sure to check your package dependencies and install the required types from the below repo. The repo has high quality TypeScript type definitions for most of the popular modules.

    https://github.com/DefinitelyTyped/DefinitelyTyped

  16. Linting: I was earlier using StandardJS, but I realized its better to have ESLint setup as it caters to both JS and TS files. This blog has covered the setting up of ESLint & Prettier for a project having both JS and TS files.

I was able to almost 70% of my JS code to TS in a week by spending 1-2 hours / day. I was now far more confident in my code while making changes or bug fixes. Now after seeing the benefits of TS, I dont think I would ever start a new project in JS without TS.