This reference guide shows how to configure a TypeScript Node.js project to work and compile to to native ESM.
CommonJS module system was introduced by the Node.js developers due to the lack of the notion of "modules" in the original JavaScript (ECMAScript) language specification at that time. However, nowadays, ECMAScript has a standard module system called ESM — ECMAScript Modules, which is a part of the accepted standard. This way CommonJS could be considered vendor-specific and obsolete/legacy. Hopefully, TypeScript ecosystem now supports the "new" standard.
So the key benefits are:
-
You get more standard and ES-compatible/portable code,
-
No more
Error [ERR_REQUIRE_ESM]: require() of ES Module not supported
errors. You can now import these annoying ESM packages by sindresorhus 😅 -
You can now use the ESM-only features like top-level await,
-
Tree shaking support if you use the module bundlers like webpack or rollup.js.
The following article could be a great prerequisite reading:
Node Modules at War: Why CommonJS and ES Modules Can’t Get Along
1). Set the type
property in the package.json
of your project to module
:
{
"name": "acme-project",
"type": "module",
// …
}
This will effectively switch node interpreter to the native ESM mode when running code of your package.
2). Make sure that all TypeScript source code files in your project are actual ES-modules: use import
statements and always specify the .js
extension in the end (don't worry, TypeScript will correctly resolve such imports). Don't use require
statements and any other features of the CommonJS.
Always try to use the ESM-versions of the third-party dependencies, e.g. lodash-es
instead of lodash
or date-fns
instead of moment
.
// GOOD
import 'polyfill';
import { camelCase } from 'lodash/es';
import { format } from 'date-fns';
import './foo.js';
import Baz, { qux } from './bar/baz.js';
// BAD: DON'T DO LIKE THIS!
import { camelCase } from 'lodash'; // "lodash" is CJS and not tree-shakable, use "lodash/es"
import * as _ from 'lodash/es'; // not tree-shaking friendly, use specific imports only
import moment from 'moment'; // moment is not ESM-friendly
import './foo'; // missing `.js` extension
import './foo.ts'; // incorrect extension
const path = `${__dirname}/data.json`; // __dirname is a CJS feature, use alternatives
3A). Use one of the ESM-bases for your TypeScript config files:
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": [
"@tsconfig/node20/tsconfig.json",
"@tsconfig/strictest/tsconfig.json",
],
"compilerOptions": {},
"include": [],
}
3B). Otherwise, set module
to ESNext
(or any ES20__
equivalents) in your tsconfig.json
.
This will make TypeScript compiler to emit your code in ESM syntax instead of converting it to CJS.
4). OPTIONAL. If you'd like you can change extension of your files to .mts
. TypeScript will automatically convert them to .mjs
during compilation. However, this is not necessary if you use the type: module
in your project's manifest. Actually, this could be used to only convert to ESM some of your source files, but I would avoid it if possible.
5). Consider adding a sideEffects
property to your package.json
. This will enable optimizations when your code is built with bundlers (this is important for tree-shaking).
The ts-node
utility allows you to execute the TypeScript code directly without emitting it to the filesystem first. It has great ESM support as well. The easiest method is to add esm: true
to your TypeScript config file:
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": [
"@tsconfig/node20/tsconfig.json",
"@tsconfig/strictest/tsconfig.json",
],
"compilerOptions": {
// …
},
"include": [
// …
],
"ts-node": {
"esm": true, // «———— enabling ESM for ts-node
},
}
Otherwise, you can use ts-node --esm
or ts-node-esm
binary invocation instead of simple ts-node
or use the node loader option:
node --loader ts-node/esm ./index.ts
The final option is to use the environment variable: NODE_OPTIONS="--loader ts-node/esm"
.
However, I would strongly recommend the esm: true
method because it allows you to configure this in a single place without affecting your call to ts-node
or node
binaries.
If you would have issues with ts-node
try looking at this section of its amazing documentation.
- TypeScript ESM support
- ts-node ESM support [documentation | issue]
- reusable tsconfig bases
- Using Jest with SWC, TypeScript, and ESM
npx tsx solves everything, a.k.a. no config ts-node. No more stupid esm to commonjs conversion, and .ts EXTENSION NOT FOUND error anymore.