Configuring TypeScript in Node.js to Emit Native ESM Code

on

ECMAScript modules (ESM) support is now considered stable in Node.js and can be enabled by setting type to module in package.json. In this tutorial, we are going to configure the TypeScript compiler to emit JavaScript code with ESM import and export statements, instead of the traditional CommonJS require() and module.exports.

We will be using Node.js v20.

node -v
v20.7.0

Setup TypeScript compiler

Start by installing the latest TypeScript compiler.

npm install typescript --save-dev
npx tsc --version
# Version 5.2.2

In your project’s root directory, create a new configuration file tsconfig.json for the TypeScript compiler:

{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "outDir": "dist",
  },
  "include": ["src"]
}
  • target: The ECMAScript version to use when transpiling TypeScript. Node.js v20 supports ES2022 features.
  • module: The module system to use. nodenext mode enables integration with Node’s ESM.
  • outDir: The output directory for JavaScript code.
  • include: The directory containing our TypeScript source.

We should also set type to module in our package.json to enable ESM support in Node.js.

{
  ...
  "type": "module",
  ...
}

Create an app

Let’s create a simple application to test the TypeScript compiler.

This is the directory structure of our project.

.
└─dist
└─src
│ ├─app.ts
│ └─MyClass.ts
└─package.json
└─tsconfig.json

In the src directory, create a file MyClass.ts:

export class MyClass {
  name: string;

  constructor(name: string) {
    this.name = name;
  }

  getMessage(): string {
    return `Hello ${this.name}`;
  }
}

In the same directory also create app.ts:

import { MyClass } from "./MyClass.js";

const myInstance = new MyClass("world");

console.log(myInstance.getMessage());

Notice that when importing MyClass in our TypeScript code we used the .js extension, despite that there is currently no MyClass.js in the filesystem, and our actual source file is named MyClass.ts. Due to the way TypeScript is currently implemented, we have to make sure that all import paths in our code point to files that will exist after TS is transpiled to JavaScript.

That is not an issue when working with CommonJS modules, since we typically import paths without adding extensions, but that approach is not compatible with ESM, as can be seen below.

TypeScript error

For those thinking that it would make more sense to point to MyClass.ts in our TypeScript code and it should be the compiler’s responsibility to rename the path to MyClass.js during the compilation step - well, you’re right. You should know that the current approach was the result of an extremely unpopular decision made by Microsoft developers.

Build & Run app

Running npx tsc will transpile TypeScript to JavaScript. Let’s put that command into package.json.

...
"scripts": {
  "start": "node dist/app.js",
  "build": "npx tsc"
},
...

Run the build script.

npm run build

In the dist directory, there should now be a app.js and a MyClass.js. Open app.js to confirm that the TypeScript compiler has emitted ESM code.

import { MyClass } from "./MyClass.js";
const myInstance = new MyClass("world");
console.log(myInstance.getMessage());

Finally, run our app.

npm start
# Hello world