Using TypeScript in Node.js
Links that I found interesting this week:
TL;DR
To use TypeScript in Node.js, you have multiple options, starting from OG
ts-node
to a more moderntsx
. If using a third-party tool for TypeScript integration is not to your liking, Node.js has recently introduced native support for TypeScript projects. Some parts of it are still in the experimental stage, like type transformations, but you can use it under the flag. While native support definitely has its benefits, you probably will have to shift your approach for writing Node.js code, in particular, the work with module systems: ESM and CommonJS. Some companies that have managed to undergo this shift report impressive numbers, such as a 40% faster app start time.
Version compatibility between Node.js and TypeScript
The first thing that we have to understand is how Node.js versions relate to TypeScript versions. Without that understanding, it’s easy to install the wrong versions of TypeScript that don’t work well with certain Node.js versions.
TypeScript compiler
The TypeScript compiler `tsc` has a minimum Node.js version requirement for it to work properly.
Starting from TypeScript version 5.1 it is required to have at least Node.js 14.17, otherwise it won’t run.
If you’re working with older Node.js versions, you simply won’t be able to use the latest TypeScript features.
Node.js type definitions
It’s not enough to just install the typescript
package in your Node.js project. Sure, you’ll get the type support and everything like that, but there won’t be typings for the Node.js APIs. If you try to use Node.js APIs like node:fs
, you’ll get an error from the TypeScript compiler.
To make things work smoothly, we need to install the @types/node
package. You want your Node.js version to match the version of the @types/node
package. For example, if you have Node.js 24.8.0, you want to install @types/node@24.8.0
.
If you have a version mismatch, you can end up in 2 equally unpleasant situations:
When you have a Node.js version higher than the typings version, some of the newer APIs won’t be available in TypeScript directly
When using Node.js with a version lower than the typings version, you might face runtime errors where
There is one catch with the latest versions of Node.js, the most recent versions of typings are not always available for those.
At the time of writing, the latest version of major version 24 is 24.8.0.
However, if you go to NPM and search available versions for @types/node
, you’ll see that only 24.5.2
is available.
If you absolutely need and want to use the latest version of Node.js available, the safest bet would be to stick with the latest version of the typings available.
The TypeScript tooling landscape
Now we’re ready to get into the actual tooling that makes Node.js work with TypeScript. Why can’t we just run Node.js with TypeScript out of the box directly using node app.js
? Well, we can actually do this with the latest advancements in the Node.js ecosystem, but it’s not as straightforward as using a compiler/bundler. Most likely, you have to change the way you write Node.js code, especially modules-related code, you’ll see the exact deatails in the upcoming section.
The TypeScript compiler
The most basic and accessible tool for running TypeScript with Node.js is the TypeScript compiler called `tsc`. It comes directly with TypeScript, meaning you don’t need to install anything extra in your project.
Let’s assume that we have a file called app.ts
to run that file with Node.js, the code has to pass several stages:
As you can see, it is a 2-step process:
We throw the TypeScript file to the
tsc
and let the compiler do the workThe file that the `tsc` produces is then fed to Node.js
To make it work automatically, you can use the --watch
mode for both tsc
and Node.js, like so:
# Run this in first shell
tsc --watch app.ts
# Run this in second shell
node --watch app.js
It’s a completely valid approach, and there is nothing wrong with it. However, the TypeScript compiler actually does a bit more than just compiling. It’s doing type checking and compiling:
The problem with type checking is the speed. It’s just slow. If you have a big project with lots of TypeScript code the loop of making changes and seeing the results becomes painfully slow when developing locally.
I’m not saying that typechecking is not important, and it definitely has its place in ensuring that the code you produce is correct. However, it’s not great for developers’ velocity.
Since TypeScript 5.6, there is a --noCheck
flag that is meant to skip some type checking and make the process faster, and it sure does its job, but even with that flag, tsc
is still slower than the alternatives.
Long gone ts-node
The OG of the TypeScript-Node.js tooling space is ts-node
. As of the time of writing, it has 31,400,000 downloads per month on NPM. To put this into perspective, TypeScript itself has approximately 93 million monthly downloads. Roughly speaking, a third of all TypeScript projects are using ts-node
.
By default, if you run ts-node
and pass a .ts
file, it will run tsc
+ node
under the hood. Basically, the 2 steps that we did in the tsc
section, but in a single shell. There is an option to skip the type checking process completely with the --transpileOnly
flag. Unlike --noCheck
that still does some type checking, this option tells `ts-node` to completely ignore type checking.
It might not be as straightforward for local development as you would think it is. The problem is that `ts-node` doesn’t have the watch mode.
ts-node focuses on adding first-class TypeScript support to Node.js. Watching files and code reloads are out of scope for the project.
Meaning, you have to introduce other tools like nodemon
to make it work in a loop where you make a change and see it gets applied automatically.
Despite its popularity, the library is not actively maintained, with the last commit dated December 12, 2023.
You can also find lots of feedback from the community that support for ESM is lacking, and despite providing options to run ESM code, it still throws errors and doesn’t work properly. Just a few issues that you can find in the GitHub repo:
”type”: “module” in package.json breaks imports and prevents from execution on node 23
ERR_REQUIRE_ESM: Require of ES Module in CommonJS Context (ts-node, svgdom)
tsx
as de facto standard
tsx
is de facto the standard for full-fledged TypeScript support in Node.js. At least because the Node.js documentation uses it for examples. They could’ve chosen any other tool, but for some reason, they chose tsx
.
tsx
doesn’t perform type checking, and it’s not even an option to enable/disable. It just completely ignores types. Of course, it’s not an option for production-facing code. To solve it, they recommend using either IDE or tsc for that.
Unlike ts-node
it can work out of the box. You don’t even need to have tsconfig.json
file for that. Basically, it is a zero-config solution. It could be a good thing if you want to get up and running with no configuration complexities.
# 1. Install tsx
# 2. Run TypeScript file directly (zero config)
tsx app.ts
# 3. Watch mode for development
tsx watch app.ts
And yes, it comes with the watch mode out of the box. No need to use any extra tools to make it work.
But you know what’s even cooler? It is a drop-in replacement for `node` with support for all of command-line flags. Meaning this code is also valid:
tsx --watch app.ts
Notice that here we’re passing the Node.js-specific --watch
flag instead of using tsx
watch mode. It just works. The quote from their docs:
tsx
is a drop-in replacement for node, meaning you can use it the exact same way (supports all command-line flags).
Under the hood, `tsx` uses esbuild to make the magic happen. Whenever you see the mention of esbuild, it means that `tsconfig.json` configuration is not going to be fully respected. Here is the list of properties that are going to be respected, everything else is ignored:
{
“compilerOptions”: {
“experimentalDecorators”: true,
“target”: “ES2022”,
“useDefineForClassFields”: true,
“baseUrl”: “.”,
“paths”: {},
“jsx”: “react”,
“jsxFactory”: “React.createElement”,
“jsxFragmentFactory”: “React.Fragment”,
“alwaysStrict”: true,
“strict”: true,
“verbatimModuleSyntax”: true,
“importsNotUsedAsValues”: “remove”,
“preserveValueImports”: true,
“extends”: “./tsconfig.json”
}
}
Despite partial tsconfig.json
support, it’s still an amazing piece of software that you should definitely try for yourself. Especially if you’re starting a new project and want to have full support of TypeScript features like paths
, enums, etc. It’s just so much easier to get things going with tsx
.
Native TypeScript support in Node.js
Now to the good part. Node.js introduced native support for TypeScript in version 23.6.0 and gradually rolled it from an experimental flag to a stable feature. Now, when you install the latest Node.js version, it comes with TypeScript support enabled by default, requiring no flags. You can run:
node app.ts
And it will work. Yay!
Of course not. It’s not as simple as it might look like.
Let’s take a closer look at what you should know before running TypeScript files natively with Node.js.
Type stripping is not a full-fledged support
One of the main features that allows Node.js to run TypeScript files natively is type stripping. What it does is simply remove types and leave blank space, essentially converting TypeScript into valid JavaScript. Easy and straightforward.
As the name suggests, it simply strips or erases the types from the code. If some TypeScript constructions go beyond type declaration and actually leave a runtime footprint, then this feature doesn’t work. There are just a few of TypeScript's features that fall under this category:
Enums
Namespaces
Parameter properties in class constructors
TypeScript decorators
Also, you should be aware that type stripping is recommended to use with TypeScript versions 5.8 and higher.
Type stripping is compatible with most versions of TypeScript but we recommend version 5.8 or newer with the following tsconfig.json settings:
The main reason I see the docs recommend this exact version is the introduction of the new TypeScript configuration option erasableSyntaxOnly
. It was specifically introduced to support Node.js, making it easier to spot errors and proactively prevent them at the static analysis level.
You can definitely use it with the lower version, however, it might not be as convenient as 5.8.
Type transformation is still experimental
Another feature introduced in Node.js is --experimental-transform-types. It’s still in an experimental stage, unlike type stripping, which is actively rolling out in all the latest Node.js versions.
Here is what Node.js documentation says about it:
Enables the transformation of TypeScript-only syntax into JavaScript code. Implies --enable-source-maps.
Basically, this flag solves the problem with non-erasable syntax. If you have it in your project and want to keep using it, then this is your best option.
The mindset shift to use native TypeScript support
As we said earlier in the post, you can’t just run `node app.ts` and expect it to just work. You have to change the way you work with modules first. What do I mean by that?
TypeScript paths are not respected
If you’re making use of TypeScript paths
to organize your code and imports more nicely, I have bad news for you. You won’t be able to run your code with Node.js native TypeScript support. It simply ignores the paths
. The closest thing that Node.js documentation offers is subpath imports. Thought they might not be as convenient for you as TypeScript paths, and they also require some mindset shift in the way you organize code.
{
“compilerOptions”: {
“paths”: {
“@/*”: [”./src/*”]
}
}
}
With this tsconfig.json
setup, we’re getting:
// Errors because paths are not respected even though configured
import { something } from ‘@src/something’;
You have to specify the file extension manually for each import
Node.js native support for TypeScript heavily relies on file extension to determine which loader to use because of that, imports that don’t have any extension error and crash the app. If you didn’t follow the Node.js way of writing JavaScript (that one also requires extensions), then you’re left with lots of imports to be changed.
There are tools that can do it for you, and at the very least, you can write a simple script that does the job, but it’s just another point of friction, especially for existing projects.
However, if we’re talking aboutg the overall idea of explicity specify the file extension I actually like it.
// Errors because file extension is not specified
import { doSomething } from ‘./do-something’;
// Works
import { doSomething } from ‘./do-something.ts’;
You have to use type
for types imports
When importing types in your code, you can do it either in the same way as all of the other code imports:
import { User } from ‘./user.ts’;
Or use the type
keyword:
import type { User } from ‘./user.ts’;
With the native TypeScript support, you have to use the type
keyword to import types. Otherwise, it will error.
To make life easier, there is a tsconfig.json
property that forces you to use the type
keyword.
{
“compilerOptions”: {
“verbatimModuleSyntax”: true
}
}
It doesn’t do future JavaScript compilation to older versions
One thing that `tsc` does is compile the new JavaScript syntax to the older versions. We’re getting the same code in the output as we run, if something is not supported by V8 then we’re out of luck, at least we can’t do much with Node.js.
No support for tsx
files
It doesn’t ship natively with support for .tsx
files. If you need to run Node.js with `.tsx` files they provide a dedicated loader you can make use of.
# Install
npm i -D @nodejs-loaders/tsx
# Run
node --import @nodejs-loaders/tsx main.js
Doesn’t parse files in the node_modules
folder
It’s pretty simple, Node.js says that it doesn’t want to degrade its performance and spend time parsing and transpiling other people’s libraries. It only cares about your project. Must say, it’s a fair point of view. The final beneficiary of such an approach is the end user.
Basically, the library authors should stick with the approach that folks from C++ land have had for ages: one file contains type declarations (the header file) and the other one contains the particular implementation.
We can configure TypeScript to work in the same way by using skipLibCheck
configuration option:
{
“compilerOptions”: {
“skipLibCheck”: true
}
}
Benefits of using the native TypeScript support
We saw that you need to adjust the way you write and think about the code to make it work properly with native TypeScript support. But what are the benefits? Are there any at all? Why should you even consider moving to native support from something like ts-node
or tsx
?
There is only one reason, and it is performance.
Marco Ippolito, the person responsible for the implementation of the native TypeScript support, posted on LinkedIn some stats from an existing project that has migrated from ts-node
to the native support. You can see the numbers for yourself.
If you’re curious about the article details, I’m leaving the link here.
The tsc
speedup is the direct result of avoiding enums, namespaces, and other non-erasable syntax.
At first, I didn’t believe the numbers for startup time, it looked too good to be true. But after running my own benchmarks, I saw an even bigger difference. I guess stripping types is simply easier than always transpiling the code, even with the modern tools like esbuild for tsx
(I was comparing tsx
with type stripping performance).
Overall, there is a clear case for migrating from tsx
and ts-node
to the native TypeScript support and type stripping if you’re struggling with TypeScript performance.
Wrap up
In conclusion, we now have 4 main options to run TypeScript in Node.js as of now:
ts-node
tsx
tsc
Native TypeScript support in Node.js for versions after 23.6.0
If you want full-fledged TypeScript support in your project, tsx
is the way to go. Written on top of node
, supports all CLI flags, and is very easy to use.
If you’re okay with not using some of TypeScript's syntax and sacrificing tsconfig.json
features like paths
, then native support could be a good choice for you. It is faster, and you don’t have to install any 3rd party packages in your project to be able to run TypeScript directly with Node.js.
Regardless of what tool you prefer, you always want tsc
running type checking somewhere in your pipeline to ensure code quality, because all of the other tools skip this step for the performance gains.