Supporting CommonJS and ESM with Typescript and Node
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:
- Github source: https://github.com/curveball/core
- Published NPM package contents: https://www.npmjs.com/package/@curveball/core?activeTab=code
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:
import * as path from 'node:path';
On the surface esModuleInterop
seems like it would be helpful, but it
has a major problem: if our libraries use esModuleInterop
, anyone
who uses that library is forced to also turn it on.
So turning by turning esModuleInterop
off we don’t force anyone
downstream into a specific setting. I generally recommend anyone writing
libraries to turn this off to reduce friction and ironically increase
interopability.
Path mapping
Typescript lets you map arbitrary paths to specific directories in your source. This is popular because it lets you do things like:
import MyController from '@controllers/my';
Instead of being forced to always do relative import
import MyController from '../../../controllers/my';
This is quite a popular feature for larger code-bases because people find relative paths annoying.
I’m also not using this feature. A big issue is that only Typescript is aware of this configuration, so any other tooling that uses your files may need to be configured separately to also understand this, which doesn’t always work.
So while I don’t have an exact list of exact reasons or configurations where this fails, it’s a common enough issue that I’ve decided to just avoid this feature altogether, for the sake of reducing magic. If you do insist on using path mapping, you’re on your own.
Configuring package.json
Modern node versions now have a syntax in package.json
that lets you
specify both the default CommonJS and ESM files. These are the relevant
lines of one of our packages:
{
"name": "@curveball/controller",
"version": "0.5.0",
"description": "A simple controller pattern for Curveball.js",
"type": "module",
"exports": {
"require": "./cjs/index.js",
"import": "./esm/index.js"
},
"main": "cjs/index.js"
}
When specifying your package.json
this way, Node will automatically load
the correct (cjs/esm) directory depending on what the user needs. This all
happens automatically. Neat!
main
is no longer used, but I’m keeping it in case of older tooling.
Building with Typescript
Before we made this change, we just had a src/
and dist/
directory
for Typescript and Javascript files respectively. To do the build, we
could just run npx tsc
.
This still works, except npx tsc
now builds to esm
for the regular
developer flow.
But when we build for creating the NPM package, we need to create both.
We use a Makefile
for this, but these are roughly the commands to run:
npx tsc --module commonjs --outDir cjs/
echo '{"type": "commonjs"}' > cjs/package.json
npx tsc --module es2022 --outDir esm/
echo '{"type": "module"}' > esm/package.json
As you can also see that both our cjs
and esm
packages get a 1-line
package.json
file with a type
property.
This is required because Node needs to know wether files with a .js
extensions should be interpreted as CommonJS or ESM. It can’t figure it
out after opening it.
Node will look at the nearest package.json
to see if the type
property
was specified.
It’s also possible to use .cjs
or .mjs
file extensions, but this
doesn’t play well with Typescript as Typescript can’t automatically adjust
import paths for you.
Changes we had to make in our code
I think it’s reasonable to say that that vanilla javascript and javascript modules are slightly different programming languages
They are interopable, but not the same. They have slightly different syntax and behavior. This is why you also need to tell HTML in advance what kind of flavour of Javascript you’re going to be using:
<script type="module" src="my-module.mjs"></script>
So naturally I expected to have to make some changes to write code in a way that works identical in both targets.
An obvious example is top-level await
which only works in ESM. Here
are all the things I ran into:
Extensions in imports
All our (local) typescript imports had to change from:
import Foo from './foo';
to:
import Foo from './foo.js';
ESM requires extensions, whereas in CommonJS it’s not needed. Here’s a sed
command I used to change all these in bulk (probably not perfect, so you’ll
really want to be able to roll this back):
find . -name "*.ts" | xargs -n1 sed -i "s/from '\(.*\)';/from '\\1.js'/g"
So the strange thing with all this is that your code will have statements like:
import Foo from './foo.js';
But actually the file in that directory is called ./foo.ts
(.ts
not .js
)
yes, this is confusing and annoying.
I don’t really want a future contributor of my library to have to understand the build chain, so it’s a leaky abstraction and a limitation of Typescript.
I hope this is rectified in the future. I understand the philosophy of wanting
to avoid magic as much as possible, but just give me a setting to globally
rewrite a .ts
to .js
when generating the .js
files.
I secretly suspect that despite Typescript explicitly not supporting this, this might still happen later anyway. Maybe they don’t want to commit to building this before they have a good plan how 🤞.
Directory imports
In CommonJS in node you can import a directory, and if a index.js
exists
in that directory, it will open that. ESM requires exact paths, so your
import * from './controllers'
Now has to be:
import * from './controllers/index.js'
__dirname
and __filename
Node defines 2 useful very variables in every CommonJS file, probably borrowed from PHP.
__dirname
is a reference to the path the current file is in, and
__filename
is the whole path + filename.
I used these to load in non-javascript assets from relative paths. For example, I had a snippet like this to get the version of the current package:
import * as fs from 'node:fs';
const pkg = JSON.parse(
fs.readFileSync(
__dirname + '../package.json'
)
);
console.log(pkg.version);
Unfortunately, these variables no longer exist in ESM. Instead we have
import.meta
. In Node, this is an object looking like this:
{
url: 'file:///home/evert/src/curveball/controller/test.mjs'
}
So instead of a filesystem path, we get a file://
url with the location. So to
write the equivalent in ESM we get something like this:
import * as fs from 'node:fs';
import * as url from 'node:url';
const pkg = JSON.parse(
await fs.readFile(
url.fileURLToPath(url.resolve(import.meta.url, './package.json')),
'utf-8',
)
);
console.log(pkg.version);
The challenge is writing this code in a way that will work with both. The
‘crazy’ way to solve this is to take an Error
object, and get the path
from the string that appears in the stack trace. This means parsing the
stacktrace :(
import * as url from 'node:url';
import * as path from 'node:path';
function getDirName() {
const err = new Error()
console.log(err.stack?.split('\n')[1]);
const fileUrl = err.stack?.split('\n')[1].match(/at .* \((file:.*):\d+:\d+\)$/);
if (!fileUrl) throw new Error('This misrable idea failed');
return path.dirname(url.fileURLToPath(fileUrl[1]));
}
console.log(getDirName());
I don’t believe that the exact format of the stack trace is stable or even the same across different interpreters, so this seems like a bad idea.
The problem is that the more obvious approach doesn’t work:
import * as path from 'node:path';
import * as url from 'node:url';
/** @ts-ignore CommonJS/ESM fun */
const dirname =
typeof __dirname !== 'undefined' ?
__dirname :
/** @ts-ignore CommonJS/ESM fun */
path.dirname(url.fileURLToPath(import.meta.url));
The above code creates a new dirname
variable. While
this works for ESM, it breaks for CommonJS on Node. The reason is that
import
is not a regular object, it’s syntax and even if the branch with
import.meta
never gets run, Node will error while parsing:
/home/evert/src/curveball/controller/cjs/dirname.js:9
path.dirname(url.fileURLToPath(import.meta.url));
^^^^
SyntaxError: Cannot use 'import.meta' outside a module
Sadness!
I don’t have a satisfying solution for this yet. I’ve worked around this in
some of my packages by avoiding this altogether, or rewriting certain files
after running them. eval('import.meta.url')
causes the ESM build to fail
because technically things running in eval
is no longer in a module scope.
Importing CommonJS modules with ‘default exports’
Some of our packages rely on 3rd party dependencies that are written in plain Javascript and use CommonJS.
In CommonJS dependencies you can ‘default’ export things like:
module.exports = function foo(a, b) { a+b; };
If we were to use this library in Typescript (CJS build) we could use it this way:
import * as WebSocket from 'ws';
const ws = new WebSocket('ws://localhost:8000');
But if we are in ESM land in Typescript, this doens’t work. We actually
get an object with a property named .default
. For these modules, this
is the approach I’ve come up with:
import * as WebSocketImp from 'ws';
// ESM shenanigans
const WebSocket = 'default' in WebSocketImp ? (WebSocketImp.default as any) : WebSocketImp;
Yep! It’s a pain. I’ve had to do this for websocket
, chai-as-promised
,
ajv
, raw-body
, accepts
and http-link-header
. In case you haven’t
figured it out, I’m building a framework.
Testing
To have confidence in both builds, I want to write tests that run against both. My tests are written using Mocha. My tests are also written in Typescript. By default, they only run against the ESM build, and I expect myself and other developers to be in this mode during the main ‘development loop.
To make Mocha understand Typescript, we need to install the ts-node
package,
and add the following properties to the root package.json
:
"mocha": {
"loader": [
"ts-node/esm"
],
"recursive": true,
"extension": [
"ts",
"js",
"tsx"
]
}
This all works perfectly well, but what about CommonJS? For this I had 2 approaches: Either reconfigure mocha to use the CommonJS Typescript loader instead of the ESM typescript loader, but this was too much of a pain to get right with writing configuration files on the fly.
Instead, I decided to just ask Typescript to build the entire project including tests in a separate directory and then just run Mocha on the javascript files.
mkdir -p cjs-test
cd test; npx tsc --module commonjs --outdir ../cjs-test
echo '{"type": "commonjs"}' > cjs-test/package.json
cd cjs-test; npx mocha --no-package
This required a separate tsconfig.json
in our test
directory.
Conclusion
So this is a fairly large amount of annoyances to work through, some without a solution. Would I recommend this?
I think if you’re in my position where:
- You’re doing this for a library.
- You consider ESM the future, but want to continue to support CommonJS users.
- The experience of users of your library is far more important than your own experience.
Then, I think this is a good idea. If you don’t want this hassle and want just one target, I think CommonJS will still give you the best experience.
ESM will probably catch up at some point, but right now I think we’re still in the awkward phase.