What is the new standard to serve both an ECMAScript Module (ESM) as well as Commonjs in the same package? How to use it? And how to make Jest
and Playwright
to work with it?
When developing an npm package, there are many considerations one needs to take regarding consumption. These considerations change with time as more tools and more practices are added and as the language evolves.
Table of Contents
Why use ECMAScript modules (import) instead of CommonJS (require)?
The most obvious difference between ESM and CommonJS is the use of import
and export
vs the use of require
and module.exports
. But that is not all…
In regards to performance, with ESM import
you can selectively load only the pieces you need. This can save you runtime memory or package size.
CommonJS (require
) loads the modules synchronously. ESM (import
), on the other hand, can be asynchronous, which allows for better performance multiple modules can be loaded concurrently.
Finally, it seems like the future is ESM, and the proposed solution in this article is an interim before CommonJS will become an obsolete method of requiring.
A new ESM Support in typescript
Recently, typescript (TS) announced the nodenext
module support from version 4.7.x. Essentially this means that consumers can now choose whether they want to consume a library with require
or import
. But besides that, nodenext
means that TS
will employ Nodejs
‘s most recent lookup strategy for (relative path) dependencies.
For anyone following the Nodejs progress, this is nothing new. ESM modules has been with us for a while now. In essence, it allows us to use the wonderful import
syntax natively in Nodejs
environment.
What is new is the official TS support of these features.
How to use nodenext
in TypeScript?
The first step of using nodenext
would be to set the module
property in the compilerOptions
to nodenext
:
// tsconfig.json
{
"compilerOptions": {
"module": "nodenext",
}
}
Once that is set, TS will look for the closest package.json
with a type
property. The type
property accepts module
or commonjs
. When “type” value is “commonjs” (or not specified), files expected to be consumed by the commonJS way (“require”).
That being said, with TS we could use import even on commonJS mode. When we did that, it worked as follows:
- You import a file without a extension –
import { cleanup } from '../utils/helpers';
. - Typescript changes the import depending on your
target
andmodule
definition intsconfig
(see the typescript documentation)
In module
mode, TS will throw an error for files without a extension so you will have to import like this: import { cleanup } from '../utils/helpers.js';
How to exempt files from the type
generalization?
You can also exempt files from this behavior by using special extensions. .mts
and .cts
are the TS equivalents of .cjs
and .mjs
. When seeing these extensions, TS handles them either as a module (.mts
) or as commonjs (.cts
). It does that disregarding the definition set in the package.json’s type
property.
This is an opt-in feature that helps you override the general configuration for a specific file.
How to specify format imports?
Nodejs allows us to specify different files for import
and require
. Historically, we had only the main
property. Now we can have more control:
{
"name": "@vonage/vivid",
"version": "3.0.0-next.4",
"type": "module",
"exports": {
"import": "./index.js",
"require": "./index.cjs.js"
},
"main": "./index.js"
}
In the example above, we set the type
to module
and we also set main
for a default behavior. The new addition here is exports
. It allows us to define what file nodejs
will look for when our consumers use import
or require
. Pretty neat.
There are a few more differences between module
and commonjs
and you can read about them in the nodejs documentation.
How to change a package from commonjs
to module
The change is seemingly simple. Just head over to the package.json
and add "type": "module"
property. So we did just that:
{
"name": "@vonage/vivid",
"version": "3.0.0-next.4",
"type": "module",
"exports": {
"import": "./index.js"
}
}
This is our simple package.json
for the web components package. When we now try to build, everything works fine. This works because our typescript version is not yet 4.7.x and we do not yet use nodenext
in our compiler. If we did that, it will break our build process because our imports are still in the “old” style:
How to make Jest
work with type: module
?
type: module
is a NodeJS definition. It is defined like this:
When running our unit tests, we get the following error:
module is not defined in ES module scope
This file is being treated as an ES module because it has a '.js' file extension and '/vivid-3/libs/components/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
The problem arise because jest
is trying to get the jest.config.js
file using Nodejs
resolver. Because the type
we set is module
, node is trying to use import
, but the file is in commonjs
format. We could set it as cjs
, and solve this issue. Here’s the commit for this one.
Another solution would be to change jest.config.js
to jest.config.json
. This will also require us to change the file to be a valid JSON. More change is less desirable.
How to make Playwright work with type: module
?
Now let’s try to run our ui-tests (we use playwright). When trying to run it, we get the following error:
The error is actually this:
SyntaxError: The requested module '@playwright/test' does not provide an export named 'PlaywrightTestConfig'
This happens because of how playwright/test
exposes its types. Because we are just using PlaywrightTestConfig
as a type, an easy fix for this issue is to import it as a type:
import type { PlaywrightTestConfig } from '@playwright/test';
Just by adding type
after import
tells TS this should not be added to the output file. It then just resolves the type for type checking and the error is resolved.
This error repeated itself throughout our test files – so we made the adjustments accordingly in all of the files (see commit).
Now when we run our tests, we stumble into a new difference between module
and commonjs
:
How to solve ReferenceError: __dirname is not defined
?
__dirname
does not exist when you use type: module
. Same goes for process
, require
, __filename
and other globals.
The ultimate solution to this is to use fileFromUrl(new URL('.', import.meta.url))
instead.
Note that you can also use the Url
directly when using fs
:
fs.readFile(new Url('./file.relative.to.script', import.meta.url))
Now our package is ready for the change to nodenext
. You can see the commit here.
Thanks go to @bradleymeck and @jackworks_asref for pointing this out.
Summary
While developing our design system ui components Vivid, we gained a lot of knowledge and experience regarding types of consumers. Our consumers vary from Vanilla JS consumers to the top notch of today’s frontend frameworks, bundlers and transpilers.
While applying new techniques that allow consumers to consume our package more flexibly, we sometimes need to change our code or infrastructure a bit. We would like to allow users to not only fetch the package – but also be able to use type check, use tree shaking and other capabilities that users expect from modern bundling and transpiling methods.
You can view the typescript announcement regarding this new API here.
You can view the changes demo branch and play with it yourself to see the errors and fixes.
Thanks a lot to Yuval Bar Levi, Miki Ezra Stanger and Yinon Oved for the kind and thorough review of this article
Featured Photo by Road Trip with Raj on Unsplash
and here’s its PR