Recently, at 3778 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:
"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
:
{
"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;
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.
{
"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:
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:
"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
:
"start": "node --unhandled-rejections=strict dist/index.js",
And let's run to see what happens:
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
:
// ...
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.
Install it (as a prod dependency, not dev, because we need it at runtime).
npm i tsconfig-paths
And change your start script. According to tsconfig-paths docs, you need to pass environment variable TS_NODE_BASEURL
to override baseUrl
from tsconfig.json:
package.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.
Let's install it as dev dependency:
npm i -D ts-node
And setup our package.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.
Top comments (0)