Supporting CommonJS and ESM with Typescript and Node – Evert Pot

I maintain a few dozen Javascript libraries, and recently updated many of them
to support CommonJS and Ecmascript modules at the same time.

The first half of this article describes why and how I did it, and then all the
hoops I had to jump through to make things work.

Hopefully it’s a helpful document for the next challenger.

A quick refresher, CommonJS code typically looks like this:

const MyApp = require('./my-app');
module.exports = {foo: 5};

And ESM like this:

import MyApp from './my-app.js';
export default {foo: 5};

Except if you use Typescript! Most Typescript uses ESM syntax, but actually
builds to CommonJS. So if your Typescript code looks like the second and
think ‘I’m good’, make sure you also take a look at the built Javascript
code to see what you actually use.

Why support ESM

The general vibe is that ESM is going to be the future of Javascript code.
Even though I don’t think the developer experience is quite there yet, but
more people will start trying ESM.

If you decided to plunge in with ESM, I want my libraries to feel first-class.

For example, I’d want you to be able to default-import:

import Controller from '@curveball/controller';

At the same time most people are still on CommonJS, and I want to continue
to support this without breaking backwards compatibility for the forseeable
future.

The general approach

My goal is for my packages to ‘default’ to ESM. From the perspective of a
library maintainer you will be dealing with ESM.

During the build phase steps are being made to generate a secondary CommonJS
build. As a maintainer you might run into CommonJS-specific issues, but
I suspect people will only see those if the CI system reports an issue.

Our published NPM packages will have a directory structure roughly like this:

- package.json
- tsconfig.json - src/ # Typescript source
- cjs/ # CommonJS
- esm/ # ESM
- test/

We include the original typescript sources to make step-through debugging work
well, and have a seperate directory for the ESM and CommonJS builds.

If you just want to skip to the end and see an example of a package that has
all this work done, check out my @curveball/core package:

Typescript features we don’t use

esModuleInterop

We don’t use the esModuleInterop setting in tsconfig.json. This flag
lets you default-import non-ESM packages like this:

import path from 'node:path';

instead of the more awkward:

Truncated by Planet PHP, read more at the original (another 29675 bytes)