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 settingbaseUrltosrc, so that when you importhelperTypeScript 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_modulesdirectory). Making this work isn’t straightforward as it requires modifying paths in the output files, so I’ll leave it for another time;rootDiris an option that allows to override the output directory structure. In our example the compiled.jsfiles are placed directly in thedistfolder, with thesrcprefix being stripped. If we wanted to preserve thesrcfolder also in the output, we would need to setrootDirtosrc.
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:
targetdefines 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;libtells 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;moduledefines 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;strictenables 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;esModuleInteropfixes 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!