DEV Community

Cover image for Node.js, TypeScript and ESM: it doesn't have to be painful
Alejandro Oviedo
Alejandro Oviedo

Posted on • Edited on

Node.js, TypeScript and ESM: it doesn't have to be painful

I was thinking into starting a Node.js project from scratch and had an unsettling choice to make: to use ESM or CommonJS.

The decision of a few popular package authors to adopt ESM, coupled with the ongoing maturity of the tooling, strongly suggests that it is the direction most of the ecosystem will converge to eventually.

In this article, my goal is to present a solution that is not only fast but it also leverages widely adopted and trusted tools.

🙅🏻‍♂️🛑 Disclaimer
This approach is thought to comply with applications running in Node.js, not on a browser. The module resolution will change drastically for other scenarios.

Make it ESM

The first step is to make the project an ES Module. To do that you only need to include "type": "module" in your package.json.
An annoying detail for me (transitioning from CommonJS) is that ES Modules require the file extension on your imports, like this:

import { hey } from './module-b.js'
Enter fullscreen mode Exit fullscreen mode

We will see in the next section how it could be improved.

tsconfig.json

This is a minimal version of a tsconfig that worked for my use case.

{
  "compilerOptions": {
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ESNext",
    "isolatedModules": true,
    "esModuleInterop": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "outDir": "dist",
    "lib": [ "esnext" ],
    "types": [ "node" ],
    "baseUrl": "./",
  },
  "exclude": [ "node_modules" ],
  "include": [
    "src/**/*.ts",
    "bin/*.ts"
  ]
}

Enter fullscreen mode Exit fullscreen mode

Notice allowImportingTsExtensions defined in there. This will allow to use your imports like the following:

import { hey } from './module-b.ts'
Enter fullscreen mode Exit fullscreen mode

Compilation

I decided to use SWC to compile the project into JavaScript, primarily because of its speed. Bear in mind SWC will not run type-checking. For scenarios where type-checking is necessary, you can still use tsc to achieve this:

{
  "scripts": {
    "build": "swc src --out-dir dist/src",
    "build:ci": "tsc && npm run build"
  }
}
Enter fullscreen mode Exit fullscreen mode

In order for the imports to work as expected this is the .swcrc config file that you will need:

{
  "exclude": "node_modules/",
  "jsc": {
    "parser": {
      "syntax": "typescript",
      "topLevelAwait": true
    },
    "target": "esnext",
    "baseUrl": ".",
    "experimental": {
      "plugins": [
        [
          "@swc/plugin-transform-imports",
          {
            "^(.*?)(\\.ts)$": {
              "skipDefaultConversion": true,
              "transform": "{{matches.[1]}}.js"
            }
          }
        ]
      ]
    }
  },
  "module": {
    "type": "nodenext"
  }
}

Enter fullscreen mode Exit fullscreen mode

Live-reload

Also often times called hot-reloading, this functionality allows to watch your application files for changes and restart it when changes occur. I'm using SWC and nodemon to perform this, with exceedingly fast results. While using two different tools seems somewhat inconvenient I like it a lot because it leads to a single-responsibility goal: SWC only takes care of compiling our code and nodemon is in charge of watching JavaScript files and restarting the Node.js process.

Here are results showcasing the average time it took the server to restart, while watching for changes and running 10 times each using the example repository:

time(avg)
tsx 260ms
SWC+nodemon 124ms

These numbers only measure from the time a change has been acknowledged to the new process has started successfully.

Why not include ts-node-dev in these benchmarks?
Sadly ts-node-dev is not currently compatible with the ts-node loader.

Running TypeScript files

There will be times when you need to run TypeScript files on its own. On Node v20 I couldn't make swc-node work for that (if you do, please let me know!), so I decided to use tsx. It works and is very simple to use.

{
  "scripts": {
    ...
    "migrate": "node --import tsx bin/run-migrations.ts"
    ...
  }
}

Enter fullscreen mode Exit fullscreen mode

Final thoughts

Using TypeScript and compiling to ESM on Node.js involves a greater degree of complexity than is often recognized. There are a lot of tweaks and knobs to turn, and tools that are supposed to work but don't. I created an example repository with all the things I mention on this article in case you want to check it out: a0viedo/node-ts-esm-example

Top comments (14)

Collapse
 
binarybitbytes profile image
BinaryBitBytes • Edited

Thank you so much for this. I seriously had this problem today when dealing with imports from typescript to js and mjs.

Collapse
 
pablitooficialok profile image
Pablo Chiappetti

Thanks Ale :) Was struggling to understand current state of Node + TS + ESM support and you nailed it here.

Collapse
 
a0viedo profile image
Alejandro Oviedo

Glad it was helpful to you Pablito!

Collapse
 
lirantal profile image
Liran Tal

Nice write-up Alejandro!

Do you find it a better alternative to avoid the "type":"module" and instead just use "main" and "module" as keys (potentially with "exports" too) to avoid the "type" declaration which hardly requires the project to run as a module too?

Collapse
 
a0viedo profile image
Alejandro Oviedo

Thanks @lirantal! I have tried alternatives but in the end type was simpler and easier for people to understand. I feel like "exports" is a rabbit hole where you need to know exactly what you're doing or you are lost.

Collapse
 
lirantal profile image
Liran Tal

Yes agree that exports makes it a bit more harder on discovery :-)
Thanks!

Collapse
 
rajeevdessai profile image
fury-r

I tried the above .swcrc file. It didn't seem to work. I checked on playground got alot of errors.Finally i tried the below config it worked.Thanks for explaining how to do it.
{
"jsc": {
"parser": {
"syntax": "typescript",
},
"target": "es2022",
"loose": false,
"minify": {
"compress": false,
"mangle": false
}
},
"module": {
"type": "es6"
},
"minify": false,
"isModule": true
}
.swcrc

Collapse
 
crazyjat profile image
Jeff Tillwick

Can someone, for the love of all that is good, please explain to me in a way that actually makes sense WHY anyone would want to import something with an extension? PLEASE! Help me understand why ANYONE would want to do this?

Collapse
 
imanabu profile image
Manabu Tokunaga

Your article was the only one that pointed out that import that requiring an extension and also the solution to include the .ts. Thank you.

Collapse
 
hatch33 profile image
Uroš Čeh

Have you tried using ts-node/register instead of tsx?

Collapse
 
a0viedo profile image
Alejandro Oviedo

I did, for me tsx seems simpler to use.

Collapse
 
cforcloud profile image
kantha g ☁️

Did you happen to try unjs.io/packages/jiti together with unjs.io/packages/listhen

Collapse
 
a0viedo profile image
Alejandro Oviedo

I have not! I might give them a try but so far I didn't had major issues with the approach described here

Collapse
 
romakyrnis profile image
Roman

Thank you!
I've run ts code using @swc-node/register/esm like so "NODE_ENV=dev node --loader @swc-node/register/esm src/index.ts"