Mixing CommonJS and ESM Imports

A Typescript Command Line Experiment

Learning Outcomes

  • how ts-node can be used to type check/transpile/run TypeScript without ceremony
  • how CommonJS and ESM modules can work together
  • understanding TypeScript configuration via tsconfig.json

Example Scenario

You're writing a command line script in TypeScript and your script uses a library that is exported in the CommonJS module format, in this case contentful-management.js@10 but you also want to use a library that is only published as an ESM module, in this case p-retry >= v5.

What do you do?

You could update these libraries to have multiple exports entries in their package.json - one for CommonJS and one for ESM - but we're wearing our library consumer hats today so let's focus on a low maintenance and timely solution - you could:

  1. make your script an ESM module and jump through some hoops to get your CommonJS dependency imported or
  2. make your script a CommonJS module and jump through some hoops to get your ESM dependency imported

Should I use CommonJS or ESM?

Let's hear from open source developer Sindre Sorhus (sindresorhus)

I would strongly recommend moving to ESM. ESM can still import CommonJS packages, but CommonJS packages cannot import ESM packages synchronously.

That's right, you need to use an await import('p-retry') if you want to get your ESM module loaded into your CommonJS project. For this article we'll take the modern approach and have our script's module use the ESM format.

Making a TypeScript ESM module

Let's bootstrap a new Node/TypeScript project with our desired dependencies.

npm init -y
npm install -D typescript ts-node contentful-management@10 p-retry@5

adding in a simple source file, we'll log the imports as evidence that both are successful.

// index.ts
import pRetry from 'p-retry' // an ESM import
import contentful from 'contentful-management' // an ESM import synthesized from a CommonJS module

const main = () => {
    console.log('pRetry is of type', typeof pRetry)
    console.log('contentful.createClient is of type', typeof contentful.createClient)
}

main()

What do you think will happen if we run the script now?

Let's roll the dice...

% npx ts-node index.ts

(node:2049) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
./index.ts:37
import pRetry from 'p-retry'; // an ESM import
^^^^^^

SyntaxError: Cannot use import statement outside a module

If unset, a package.json's type defaults to commonjs but we want our project to use ESM, so let's follow the recommendation in the warning:

To load an ES module, set "type": "module" in the package.json

{
    "type": "module"
}

Now if we run the script again...

% npx ts-node index.ts

pRetry is of type function
contentful.createClient is of type function

Huh! It executes the script even though we haven't created a tsconfig.json TypeScript config file; this works thanks to ts-node's default behaviour:

If no tsconfig.json is loaded from disk, ts-node will use the newest recommended defaults from @tsconfig/bases compatible with your node and typescript versions.

Let's learn a bit about these defaults so that we understand our tools a bit better, there's a helpful line in the ts-node documentation.

When in doubt, ts-node --showConfig will log the configuration being used.

This command shows you the default values that you would otherwise have to define in your project's tsconfig.json file. Let's see what ts-node is giving us for free.

% npx ts-node --showConfig
{
  "ts-node": {
    "esm": true
  },
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "sourceMap": true,
    "inlineSourceMap": false,
    "inlineSources": true,
    "declaration": false,
    "noEmit": false,
    "outDir": "./.ts-node",
    "target": "es5"
  }
}

The first config item tells ts-node that when it runs it should configure the Node runtime to use an ESM module loader.

  "ts-node": {
    "esm": true
  },

The rest of the config items are namespaced under compilerOptions, let's look at the bare essentials i.e. the config items that would cause the script to go boom if they weren't defined:

  1. "module": "esnext"

    With esnext our module has access to the foremost javascript features, some of which are necessary for ESM modules e.g. top level await syntax.

  2. "moduleResolution": "node"

    Despite the fact that ts-node hooks a custom ESM module loader into Node, our module will appear as CommonJs to the Node engine once ts-node has worked it's magic, hence this config item says "hey Node, let's use CommonJS" - eventually Node's ESM support will stabilise and Node won't need as much of a helping hand from the likes of ts-node.

  3. "esModuleInterop": true

    We want ESM and CommonJS modules to be interoperable, for our ESM module that means automatically converting any CommonJS module imports into ESM module imports.

Now that we know why we need these config items, rather than leaving our config to the gods, let's add these essential items to our own tsconfig.json.

Let's get a bit fancy and create an alias for a Node package that can set values in our package.json.

alias tsconfig="npx dot-json tsconfig.json"

Now we can update tsconfig.json using the tsconfig alias.

tsconfig ts-node.esm true
tsconfig compilerOptions.module esnext
tsconfig compilerOptions.moduleResolution node
tsconfig compilerOptions.esModuleInterop true

We may as well make an alias for updating package.json as well.

alias package="npx dot-json package.json"
package type module
package main index.ts

Let's run the script again, this time it won't be using ts-node's default ts-config.json

% npx ts-node index.ts

pRetry is of type function
contentful.createClient is of type function

Voilà! It still works, and this time you've got an understanding of how and why we configured TypeScript/Node and ts-node.

Let's Review the Steps

  1. init node package w/defaults
  2. install dependencies
  3. write a typescript file that imports an ESM module and a CommonJS module
  4. update package.json properties
  5. update tsconfig.json properties
  6. type-check/transpile/run it! using ts-node

For those who'd like to play with the code, here's the steps in this article:

npm init -y
npm install -D typescript ts-node contentful-management@10 p-retry@5

alias package="npx dot-json package.json"
package type module
package main index.ts

alias tsconfig="npx dot-json tsconfig.json"
tsconfig compilerOptions.module esnext
tsconfig compilerOptions.moduleResolution node
tsconfig compilerOptions.esModuleInterop true
tsconfig ts-node.esm true

printf "import pRetry from 'p-retry' // an ESM import
import contentful from 'contentful-management' // an ESM import synthesized from a CommonJS module

const main = () => {
    console.log('pRetry is of type', typeof pRetry)
    console.log('contentful.createClient is of type', typeof contentful.createClient)
}

main()
" >> index.ts

npx ts-node index.ts

Dictionary of Terms

CJS

CJS stands for CommonJS module

CommonJS modules are the original way to package JavaScript code for Node.js

nodejs.org/docs/latest-v18.x/api/modules.html

ESM

ESM stands for ECMAScript module

Node.js also supports the ECMAScript modules standard used by browsers and other JavaScript runtimes.

nodejs.org/docs/latest-v18.x/api/modules.html

npx

npx

This command allows you to run an arbitrary command from an npm package (either one installed locally, or fetched remotely), in a similar context as running it via npm run.

npm help npx

ts-node

ts-node -- TypeScript execution engine and REPL for Node.js.

It JIT transforms TypeScript into JavaScript, enabling you to directly execute TypeScript on Node.js without precompiling. This is accomplished by hooking node's module loading APIs, enabling it to be used seamlessly alongside other Node.js tools and libraries.

github.com/TypeStrong/ts-node#overview

Did you find this article valuable?

Support Graham Towse by becoming a sponsor. Any amount is appreciated!