Enabling TypeScript on an existing JavaScript project with custom path mapping (path alias)
Tags: typescript, javascript, tutorial
Published: 2023-07-04 17:45:14

Recently, at [3778](https://www.3778.care/) healthtech, we wanted to enable TypeScript support on a 3-year old JavaScript backend project and gradually migrate code to TS.

In short, our motivation was mainly to provide type safety (catch type-related errors during compilation/transpilation) for the developers.

This project had some characteristics:
- it used Babel as transpiler;
- it had Babel configured to allow path alias (thus avoiding the relative path hell)
- it used Jest for unit testing.

`package.json` excerpt:
```json
  "scripts": {
    "start": "node build/index.js",
    "dev": "nodemon --inspect=0.0.0.0:56745 --exec babel-node src/index.js",
    "build": "babel --delete-dir-on-start --copy-files --no-copy-ignored -d ./build src",
    // ...
  }
```

`.babelrc`:
```json
{
  "presets": [
    "@babel/preset-env"
  ],
  "plugins": [
    ["module-resolver", {
      "root": ["./src"]
    }],
    ["@babel/plugin-proposal-decorators", {
			"legacy": true
		}],
    ["@babel/plugin-proposal-optional-chaining"],
    ["@babel/plugin-proposal-class-properties"],
    ["@babel/plugin-proposal-object-rest-spread"],
    ["@babel/plugin-transform-destructuring"],
    ["@babel/plugin-transform-runtime"],
    ["lodash"]
  ]
}
```

Our Babel config (module-resolver plugin, to be most precise), allowed us to do this kind of import, always relative to `./src` folder:
```
import MyService from "services/MyService";
```

So, here's how I did it.

## Install and setup TypeScript

First, install the needed dev dependencies:

- `typescript`: TypeScript itself (version 5.x was just released, so it's the one we chose);
- `@tsconfig/node14`: A base tsconfig file for the Node version we use;
- `@types/*`: Types for the packages we use so we can take the most out of TS typing feature. For instance, `@types/jest`, `@types/ramda`, etc;

```bash
npm i -D typescript @tsconfig/node14 @types/jest @types/ramda # other @types 
```

Then create your tsconfig.json. This is very specific to the current needs of each project, but there are some interesting features for a project where TypeScript and JavaScript source code will coexist for ~~probably a long~~ a while.

Here are the most relevant options:
- `extends`: uses the base config we imported earlier
- `baseUrl`: sets a base directory from which to resolve non-relative module names. Very useful for path aliasing. 
- `allowJs`: allow .js files
- `checkJs`: whether .js files should be checked or no. In my experience, when migrating a legacy project, you probably want this disabled.
- `exclude`: exclude files from compilation, such as node_modules and your test files.

```json
{
  "extends": "@tsconfig/node14/tsconfig.json",
  "include": ["src/**/*"],
  "exclude": ["node_modules", "tests/**/*", "**/*.{spec,test}.{js,ts}"],
  "compilerOptions": {
    "baseUrl": "./src",
    "rootDirs": ["src"],
    "incremental": false,
    "allowJs": true,
    "checkJs": false,
    "sourceMap": true,
    "outDir": "dist",
    "strict": true,
    "skipLibCheck": true
  }
}
```

## Make the compilation work!

To check if your setup is correct, you can invoke `tsc`, the TypeScript compiler, directly:

```bash
npx tsc
```

Sometimes you may get an error here, depending on your `tsconfig.json` configuration. This may happen because you don't have any .ts files inside your root dir. In such case, just rename your entry-point file to .ts.

In case of `Cannot find module 'xyz' or its corresponding type declarations.`, you probably need to set the `baseUrl` property to the correct value, or even use the `paths` property to map your path aliases.

Then you can adjust your NPM scripts. Edit the scripts in `package.json`. Here's our setup:

```json
  "scripts": {
    "build": "tsc",
    "build:clean": "rm -rf dist && npm run build",
    "build:watch": "npm run build -- --watch",
  },
```

## Run your app

The compilation is just the first step. We need now to make sure our app can start up properly. So, adjust your start script on `package.json`:

```json
    "start": "node --unhandled-rejections=strict dist/index.js",
```

And let's run to see what happens:
```bash
npm run build
npm start
```

Here is my output - it wouldn't be that easy, right ;) :
```
Error: Cannot find module 'config/vars'
Require stack:
- /home/user/app/dist/index.js
    at Function.Module._resolveFilename (internal/modules/cjs/loader.js:902:15)
    ...
(internal/modules/run_main.js:75:12) {
  code: 'MODULE_NOT_FOUND',
    ...
} Uncaught Exception thrown
```

Depending on your project, you may not have any issues here. In my case, this is happening because, although we instructed `tsc` to support our path aliases, the generated .js keeps the same paths.

Here is an excerpt of my generated `dist/index.js`:
```js
// ...
require("config/vars");
// ...
```

There are some ways to fix this. After looking into packages that can solve this issue, I ended up using [tsconfig-paths](https://github.com/dividab/tsconfig-paths).

Install it (as a prod dependency, not dev, because we need it at runtime).
```bash
npm i tsconfig-paths
```

And change your start script. According to tsconfig-paths [docs](https://github.com/dividab/tsconfig-paths#with-node), you need to pass environment variable `TS_NODE_BASEURL` to override `baseUrl` from tsconfig.json:

`package.json`:
```json
    "start": "TS_NODE_BASEURL=./dist node --unhandled-rejections=strict -r tsconfig-paths/register dist/index.js",
```

Run `npm start` again and success!

## Setup your development script

During development, you don't want to build the project every time in order to execute it. So let's configure our `dev` NPM script.

This time we need a different tool, one that can transpile our code JIT (Just-In-Time). The most used one is [ts-node](https://github.com/TypeStrong/ts-node).

Let's install it as dev dependency:
```bash
npm i -D ts-node
```

And setup our `package.json`:
```json
    "dev": "ts-node -r tsconfig-paths/register ./src/index.ts",
```

As you can see, we still need to use tsconfig-paths, so we require its register. And we run directly our entry-point .ts file.

## Conclusion

I hope this tutorial helped you on adding support for TypeScript with path alias enabled.

In case you have a Dockerfile, remember to adjust it accordingly:
- COPY tsconfig.json
- and adjust the entry point CMD. 

I intend to do a follow-up on this article showing how to set up Jest and ESLint to work with TypeScript, as well as source-map support and hot-reload for development.

If you like it, please share with your friends, coworkers or someone who may like it.

If you have any doubts, suggestions or corrections, please feel free to contact me.