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.)
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",
// ···
}
// ···
}
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,
}
}
"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
."moduleResolution"
): This value is needed for Node.js."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
.
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.
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):
^(import [^';]* from '(\./|(\.\./)+)[^';.]*)';
$1.js';
In this section, we explore how package exports work. They are specified via property "exports"
in package.json
and support two important features:
"exports"
, every module in my-package
can be accessed via deep import paths such as 'my-package/subdir/subsubdir/any-module.js'
.Recall that this is the file structure of the package:
my-package/
ts/
src/
main.ts
util/
errors.ts
test/
dist/
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';
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.
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
}
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';
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';
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.
Package exports have two important benefits:
Both benefits help with providing a nicely abstract interface to code using our package. In fact, the recommended style for module specifiers is now:
'../tools/config-parser.js'
'format-checker/strict'
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.