TypeScript and native ESM on Node.js

[2022-11-24] dev, typescript, esm, nodejs
(Ad, please don’t block)

In this blog post, I’ll explain everything you need to know in order to use and produce native ECMAScript modules on Node.js.

The GitHub repository iterable is an example of a TypeScript ESM package that works on Node.js. It still uses the "typesVersions" workaround (which isn’t needed in TypeScript 4.7 and later).

(Thanks to Guy Beford and Oleg Drapeza for their feedback on this post.)

TypeScript 4.7: better support package exports and Node’s ESM  

Starting with TypeScript 4.7, there is no need for adding "typesVersions" to package.json anymore because TypeScript now understands package exports.

If you are writing Node.js code, the following settings in tsconfig.json can help:

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    // ···
  }
  // ···
}

The basics  

Let’s assume we are implementing an npm package my-package via TypeScript. The package has the following file structure:

my-package/
  ts/
    src/
      main.ts
      util/
        errors.ts
    test/
  dist/

tsconfig.json  

{
  "compilerOptions": {
    "rootDir": "ts",
    "outDir": "dist",
    "target": "es2020",
    "lib": [
      "es2020", "DOM"
    ],
    "module": "ES2020", // (A)
    "moduleResolution": "Node", // (B)
    "strict": true,
    "sourceMap": true,
    // Needed for CommonJS modules
    "allowSyntheticDefaultImports": true, // (C)
    //
    "declaration": true,
  }
}
  • Line A ("module"): We are telling TypeScript to generate ECMAScript modules.
    • "ES6", "ES2015": support for basic ESM features
    • "2020": additionally, support for dynamic imports and import.meta.
  • Line B ("moduleResolution"): This value is needed for Node.js.
  • Line C ("allowSyntheticDefaultImports"): I needed this setting in order to import a legacy CommonJS module. The module.exports were the default export in that case.

package.json  

The following entry is needed in package.json:

"type": "module"

Specifying a "type" is recommended anyway, but a must here because TypeScript doesn’t support .mjs yet, only .js.

TypeScript  

If the package is npm-installed in another package, this is how that package imports the function start():

import {start} from 'my-package/dist/src/main.js';

The key point is that, by default, we need to provide filename extensions, especially for modules within the same package.

Visual Studio Code  

By default, VS Code does not add filename extensions when it adds imports for us. That can be changed via the following two settings:

"javascript.preferences.importModuleSpecifierEnding": "js"
"typescript.preferences.importModuleSpecifierEnding": "js"

This is how we can add filename extensions to existing local imports (within a package):

  • Search: ^(import [^';]* from '(\./|(\.\./)+)[^';.]*)';
  • Replace: $1.js';

Package exports: hiding package internals and providing nicer module specifiers  

In this section, we explore how package exports work. They are specified via property "exports" in package.json and support two important features:

  • Hiding internals:
    • Without property "exports", every module in my-package can be accessed via deep import paths such as 'my-package/subdir/subsubdir/any-module.js'.
    • Once it exists, only specifiers listed in it can be used. Everything else is hidden from the outside.
  • Nicer module specifiers:
    • Module specifiers without filename extensions. That is now the recommended format for exported (non-local) modules.
    • Shorter paths for deeply nested modules.

Recall that this is the file structure of the package:

my-package/
  ts/
    src/
      main.ts
      util/
        errors.ts
    test/
  dist/

An entry point for the package itself  

package.json:

{
  "main": "./dist/src/main.js",
  "exports": {
    ".": "./dist/src/main.js"
  },
  "typesVersions": {
    "*": {
      "main.d.ts": ["dist/src/main.d.ts"]
    }
  }
}

We only provide "main" for backward-compatibility.

"typesVersions" performs the same mapping as "exports", but for TypeScript’s type definitions.

This is the import statement in TypeScript:

import {start} from 'my-package';

Nicer module specifiers for a subtree  

package.json:

{
  "exports": {
    "./*": "./dist/src/*"
  },
  "typesVersions": {
    "*": {
      "*": ["dist/src/*"]
    }
  }
}

Here, we shorten the module specifiers of the whole subtree under my-package/dist/src:

import {InternalError} from 'my-package/util/errors.js';

Without the exports, the import statement would be:

import {InternalError} from 'my-package/dist/src/util/errors.js';

Note the asterisks in this "exports" entry:

"./*": "./dist/src/*"

These are not filesystem globs but instructions for how to map external module specifiers to internal ones.

Exposing a subtree while hiding parts of it  

With the following trick, we expose everything in directory my-package/dist/src/ with the exception of my-package/dist/src/internal/

"exports": {
  "./*": "./dist/src/*",
  "./internal/*": null
}

Exporting files anywhere inside a directory without filename extensions  

package.json:

{
  "exports": {
    "./lib/*": "./dist/src/*.js"
  },
  "typesVersions": {
    "*": {
      "lib/*": ["dist/src/*"]
    }
  }
}

Any file that is a descendant of ./dist/src/ can be imported without a filename extension:

import {start}         from 'my-package/lib/main';
import {InternalError} from 'my-package/lib/util/errors';

Mapping a directory to a file  

package.json:

{
  "exports": {
    "./util": "./dist/src/util/errors.js"
  },
  "typesVersions": {
    "*": {
      "util": ["dist/src/util/errors.d.ts"]
    }
  }
}

Here, we want to export ./dist/src/util/errors.js as if it were the directory in which it resides:

import {InternalError} from 'my-package/util';

Advanced package exports features  

Node.js supports more exports features, for example conditional exports. We can change our mapping depending on how our package is imported – e.g. via import or via require():

"exports": {
  ".": {
    "import": "./main-module.js",
    "require": "./main-require.cjs"
  }
}

Additionally, it’s possible to distinguish between browsers and Node.js, and more.

Recommendations for using package exports  

Package exports have two important benefits:

  • We can hide internals.
  • We get nicer module specifiers.

Both benefits help with providing a nicely abstract interface to code using our package. In fact, the recommended style for module specifiers is now:

  • Importing from within the current package: Use filename extensions in module specifiers.
    • Example: '../tools/config-parser.js'
  • Importing from another package: Avoid filename extensions in module specifiers.
    • Example: 'format-checker/strict'

What about browsers?  

So far, I have only tested this setup on Node.js, but I’d assume that bundlers will support these features, too. If not already, then eventually.

I’m happy to see that ESM is supported increasingly well everywhere. For example, I moved the test-driven exercises for my book “JavaScript for impatient programmers” (in plain JavaScript) to native ESM on Node.js a while ago, and that worked really well.

Further reading