Cover

What Surprised Me About ECMAScript Modules

February 1, 2023
No Comments.

I’ve spent the last couple of months working on a new Pluralsight course about Modules in JavaScript. I’ve been writing JavaScript (and TypeScript) for a lot of years. But digging into the course made me understand how some of this modularity actually worked. Let’s talk about some things that surprised me.

I also made a Coding Short video that covers this same topic, if you’d rather watch than read:

ECMAScript Modules in Node.js

While Node.js has had module support long before ECMAScript got it’s act together and started supporting modules. CommonJS was an early standard for exposing modules. So most of the Node.js projects i’ve worked on just supposed that I had to use CommonJS. For example, a simple import using CommonJS (e.g. require()):

// index.js
const invoices = require("./invoices.js");

Since EMCAScript Modules (ESM) are supported, you could just name your index.js (in our case) to index.mjs and it would allow us to use EMCAScript:

// index.mjs
import invoices from "./invoices.mjs";

But, for me, I like that Node.js allows us to change the default module type to ESM:

{
  "name": "before",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module", // commonjs is the default
  "scripts": {
    "start": "node ./index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "lodash": "^4.17.21"
  }
}

Then we can just use ESM in our code without the renaming:

// index.js
import invoices from "./invoices.js";

Consuming CommonJS from ECMAScript

So, you can use ESM to load all your own code where you’ve defined your modules directly. This works with your own projects or npm packages. If you’re using it for your own projects, you can rename your project to .cjs and it will be treated as a CommonJS:

// invoices.cjs
module.exports = [...];

But, more commonly, npm packages are mostly defined as CommonJS modules. How do we use them? For example, we can bring in a npm package (in this case lodash) like so:

import lodash from "lodash";

This allows us to use the lodash object as you like. But there is a limitation. Ordinarily, you could destructure it to get just the round function we need:

import { round } from "lodash";

But ESM with Node.js, it doesn’t work. It is because of a fundamental difference in how CommonJS defines named element and how ESM does it. So, to do it, you will need to import it as the default, but then you can destructure manually:

import lodash from "lodash";
const { round } = lodash; 

It’s a minor nit, but if you know how CommonJS modules defined names (as I show in my course), it actually makes sense.

Deferred Imports

I’ve been using ESM for a while and never ran into the import() function. I used to think that CommonJS was the only module system that allowed for late binding imports. But, alas, I was wrong.

The import function allows you to request an import at runtime, though it is asynchronous so you have to deal with the promise. For example:

export async function calculateTotal(invoice) {
  const { taxRates } = await import("./taxRates.js");
  const rate = taxRates[invoice.state];
  const total = invoice.amount + invoice.amount * rate;
  return {
    rate,
    total,
  };
}

You can see that the import allows you to load the module the first time we use calculateTotal(). This does mean that you have to deal with asynchrony with the caller too:

invoices.forEach(async i => {
  const { rate, total } = await calculateTotal(i);
  console.log(`Invoice: ${i.invoiceNumber}, Date: ${i.invoiceDate}
  Gross:    $${round(i.amount,2)}
  Tax Rate: ${rate * 100}% 
  Net:      $${round(total,2)}`);
});

Note that the foreach is now async and you can use await to deal with the asynchrony.

You can find the example of the project here:

Module Example