If you’re just starting out with TypeScript with Node.js, you might have found yourself confused by the fact that there isn’t a single way of configuring TypeScript with your project.
While there are lots of tools and frameworks, in this article I’ll introduce a minimal setup for a new empty Node.js application without using any framework. This is something I wish the official TypeScript documentation had (or maybe I missed it).
Initialization
The first thing you’ll want to do is initialize the project with npm init
.
You can then install TypeScript as a development dependency:
npm install --save-dev typescript
The source TypeScript files will be placed in the src
directory, so this is the structure you should have by now:
├── package.json
├── package-lock.json
└── src
└── index.ts
tsconfig.json
While you could theoretically already use the TypeScript compiler to check types and transpile the .ts
file to .js
, you would usually create a tsconfig.json
file to properly configure the compiler.
Since we’re using Node.js to build a backend application (thus not for the browser), we can choose a configuration that is specific for the Node.js version we’re using. This makes sure that modern JavaScript features are used directly if available, avoiding polyfills, etc.
At the time of writing, the Node.js LTS version is 16. The TypeScript team publishes a base tsconfig.json
configuration for Node.js 16 with the @tsconfig/node16
npm package.
So let’s install the package:
npm install --save-dev @tsconfig/node16
And create a tsconfig.json
file with the following content:
{
"extends": "@tsconfig/node16/tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": [
"src/**/*.ts"
]
}
The most important part is the extends
property, where we’re basically importing the base configuration provided by the @tsconfig/node16
package. This base configuration contains some good defaults which we’re going to take a look to in a moment. This is also the reason why we don’t need to specify almost any compilerOptions
(you might find pretty long tsconfig.json
files online), since the base config file already contains best practises.
The include
property defines where to look for TypeScript files to compile. In this case we’re including all .ts
files in the src
directory. If we didn’t put it, TypeScript would for example attempt to compile the node_modules
directory. In some examples you might also see the exclude
property being used to exclude node_modules
, but with the structure we’re using here it’s not required (since node_modules
is outside src
).
The compiler option outDir
tells TypeScript where to output the transpiled .js
files. If not specified, .js
files are placed in the same directory where the corresponding .ts
files lie.
You might sometimes find the following options in tsconfig.json
examples:
baseUrl
, which is supposed to allow non-relative imports. For example you might get to a situation where you writeimport helper from '../../helper'
, and you instead want to simplify the thing and writeimport helper from 'helper'
. In our example this would require settingbaseUrl
tosrc
, so that when you importhelper
TypeScript knowns that you’re actually importingsrc/helper
, starting from the root. This indeed works at the TypeScript level, meaning that the compiler doesn’t complain anymore, but it doesn’t work in practice because the Node.js module resolution doesn’t work in the same way and it wouldn’t find the module at runtime (Node.js looks for non-relative imports only in thenode_modules
directory). Making this work isn’t straightforward as it requires modifying paths in the output files, so I’ll leave it for another time;rootDir
is an option that allows to override the output directory structure. In our example the compiled.js
files are placed directly in thedist
folder, with thesrc
prefix being stripped. If we wanted to preserve thesrc
folder also in the output, we would need to setrootDir
tosrc
.
About @tsconfig/node16
In addition to the above, our tsconfig.json
file will include some additional compiler options from the @tsconfig/node16
package:
{
"compilerOptions": {
"lib": ["es2021"],
"module": "commonjs",
"target": "es2021",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node"
}
}
In short, this is what the options do:
target
defines which JavaScript specification we’re using, and thus the syntax and APIs that are allowed in the output JavaScript. Here we’re using ES2021, since Node.js 16 fully supports it;lib
tells TypeScript to include type definitions for built-in APIs defined by ES2021. According to the docs it is automatically set to the same value oftarget
, but here they made it explicit anyway;module
defines that the JavaScript code should use CommonJS for imports, i.e. it should userequire()
to import modules. Although Node.js 16 adds native support for the new ECMAScript Modules (ESM) syntax (the one you actually always use in TypeScript when you writeimport helper from 'helper'
), this is still a new thing and this config doesn’t make use of it. You can read more about ESM here. Note that this option also modifiesmoduleResolution
, which is however made explicit anyway in this config file;strict
enables a set of stricter checks that should really be the default. You can read more about the “strict mode family” options in the official docs;esModuleInterop
fixes problems when trying to import CommonJS modules with the ESM syntax. This also enablesallowSyntheticDefaultImports
, which simply allows you to write something likeimport moment from 'moment'
instead ofimport * as moment from 'moment'
.
Runing the compiler
To run the TypeScript compiler we’ll use the tsc
CLI tool.
In your terminal, run:
./node_modules/typescript/bin/tsc
And that’s it! The compiler automatically reads the tsconfig.json
file so it knows what to do. The output .js
files should now be in the dist
directory.
A better way to run tsc
is to make use of npm scripts. Modify your package.json
file so that it contains a scripts
section as follows:
{
// ...
"scripts": {
"build": "tsc"
}
}
Then you can run the build with:
npm run build
To actually run the code, just use:
node dist/index.js
Or even better, create a start
npm script:
{
// ...
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
Watch + reload
While you’re developing, you will often need to recompile and restart the application. Instead of manually running npm run build && npm start
each time, there’s a better way.
The tsc
compiler allows to enable the “watch” feature with the --watch
option: in this way the compiler will recompile the output each time a source file is changed.
This approach still requires you to manually run and restart the actual Node.js application, so a better solution could be to use the tsc-watch
npm package.
You can install it with:
npm install --save-dev tsc-watch
And then add a new npm script that looks like the following:
{
// ...
"scripts": {
// ...
"dev": "tsc-watch --onSuccess \"node dist/index.js\"
}
}
So if you run npm run dev
you’ll have the Node.js application automatically recompiled and restarted whenever your source code changes.
(You might find the --noClear
option of tsc-watch
useful, as it disables clearing the terminal at each build.)
This is just one way of doing it, there are many other libraries and approaches.
Conclusion
That’s it! This was a minimal example of how to setup TypeScript with a Node.js backend application.
In practice you would probably want to add more stuff, when the project grows. This usually includes ESLint for linting and something like Prettier for enforcing a consistent code style.
If you’re building a web application, using a framework like NestJS is also probably a good idea.
Feel free to leave a comment if you have suggestions or doubts!