Skip to content

Instantly share code, notes, and snippets.

@slavafomin
Last active April 22, 2024 23:14
Show Gist options
  • Star 49 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save slavafomin/cd7a54035eff5dc1c7c2eff096b23b6b to your computer and use it in GitHub Desktop.
Save slavafomin/cd7a54035eff5dc1c7c2eff096b23b6b to your computer and use it in GitHub Desktop.
Using TypeScript with native ESM

Using TypeScript Node.js with native ESM

This reference guide shows how to configure a TypeScript Node.js project to work and compile to to native ESM.

Rationale

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

Migration steps

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.

EXAMPLES

GOOD

// GOOD
import 'polyfill';
import { camelCase } from 'lodash/es';
import { format } from 'date-fns';

import './foo.js';
import Baz, { qux } from './bar/baz.js';

BAD

// 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).

Using with ts-node

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.

Additional links

Subscribe for more tips!

ESM usage with Pulumi

Pulumi is a great IAAS solution that allows you to write your infra using TypeScript code.

With a little configuration you can use ESM modules with it as well:

// package.json
{
  "name": "my-pulumi-project",
  "type": "module",
  "engines": {
    "node": ">=18"
  },
  "devDependencies": {
    "@types/node": "^18.11.9",
    "ts-node": "^10.9.1",
    "typescript": "^4.9.3"
  },
  "dependencies": {
    // use deps for your cloud provider
    "@pulumi/aws": "^5.21.1",
    "@pulumi/awsx": "^1.0.0",
    "@pulumi/pulumi": "^3.48.0"
  }
}

Looks like Pulumi is using some outdated version of TypeScript compiller so it's not possible to use bases. However, here's the working TS config based on @tsconfig/node-lts-strictest-esm:

// tsconfig.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "lib": [
      "es2020"
    ],
    "module": "es2020",
    "target": "es2020",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "allowUnusedLabels": false,
    "allowUnreachableCode": false,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "importsNotUsedAsValues": "error",
    "outDir": "bin"
  },
  "include": [
    "src/",
    "index.ts"
  ]
}
# Pulumi.yaml

# https://www.pulumi.com/docs/reference/pulumi-yaml/

name: my-pulumi-project
description: Pulumi is great!
runtime:
  name: nodejs
  options:
    # this is required for ESM loader to work correctly
    # the tsconfig.json method is not working with Pulumi
    nodeargs: "--loader ts-node/esm"

Using ESLint to correct auto-import extension

Sadly, the editor that I'm working in (WebStorm) is awful in detecting the import filename extensions, the provided "automatic" mode doesn't work correctly and just omits the .js extensions.

However, the following ESLint plugin will help to restore the order.

Just install the plugin:

npm i -D eslint-plugin-require-extensions

Then add the following to your ESLint config:

{
  "extends": [
    "plugin:require-extensions/recommended"
  ],
  "plugins": [
    "require-extensions"
  ]
}

Check your imports:

eslint .

Fix your imports automatically:

eslint . --fix
@pencilcheck
Copy link

pencilcheck commented Jan 1, 2024

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.

@jlguenego
Copy link

jlguenego commented Feb 2, 2024

@pencilcheck tsx is effectively a great tool.
One exception: if you need to generate the javascript project, you cannot.
I propose another project (disclaimer: I am the author) :
https://github.com/jlguenego/esbuild-watch-restart
😊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment