ECMAScript Module support in Typescript 4.7

How we employed the new ECMAScript Module Support in typescript

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.

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:

  1. You import a file without a extension – import { cleanup } from '../utils/helpers';.
  2. Typescript changes the import depending on your target and module definition in tsconfig (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:

A named import with a relative path. The design-system module is missing the file type extension. This will fail in module mode with ts > 4.7.x with nodenext configuration.

How to make Jest work with type: module?

type: module is a NodeJS definition. It is defined like this:

The “type” field defines the module format that Node.js uses for all .js files that have that package.json file as their nearest parent.

Files ending with .js are loaded as ES modules when the nearest parent package.json file contains a top-level field “type” with a value of “module”.

If the nearest parent package.json lacks a “type” field, or contains “type”: “commonjs”, .js files are treated as CommonJS. If the volume root is reached and no package.json is found, .js files are treated as CommonJS.

Taken from https://nodejs.org/api/packages.html

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 when running our playwright ui-tests

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:

ReferenceError: __dirname is not defined

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

Sign up to my newsletter to enjoy more content:

5 1 vote
Article Rating
Subscribe
Notify of
guest

1 Comment
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Yinon
1 year ago

and here’s its PR

Last edited 1 year ago by Yinon