JavaScript: Modules
A bedtime story that turned a nightmare, but ends well
Let’s put it simply: modules are a mess in the JavaScript world. Not speaking about TypeScript, where it gets even messier. Try to mix both, and it becomes suffering. Why that?
Actually this is more about the history of JavaScript than its latest choices and standards: over the years, different modules systems were developed for it: AMD (RequireJS), CommonJS, UMD, SystemJS, ES6. As a result, ensuring interoperability between them became a hell of a task.
What’s a module?
A set of software items such as functions, classes or even resources (files). Just like a class encapsulates methods or a big function can aggregate nested ones, it is way to abstract a set of reusable components or services without publishing its internals.
And, as anything that wraps, it implies:
- a private/local scope, where your implementation details are isolated from unallowed access and name collision;
- a list of exports that constitute the public contract with your users.
A pattern
In the beginning, there was nothing, so the only way to have modules was to implement them by yourself. A first naive approach could be to reduce it as an object providing the expected API. For instance:
var myModule = {
internalVar = "intern"
internalFunc: function(params) {
// use internalVar
}
apiFunc1: function () {
internalFunc(null)
}
apiFunc2: function (params) {
internalFunc(params)
}
}
result1 = myModule.apiFunc1()
result = myModule.internalFunc() // Works but shouldn't be allowed
Here internalFunc
is accessible whereas it shouldn’t. Since there were no property access modifiers (later TypeScript would feature private
, protected
, public
modifiers, an the #
private field prefix would finally land in JS), there was no proper way to encapsulate/hide such internal members.
In order to properly hide such a hand-crafted module internals, Richard Cornford and others (then later Douglas Crockford) recommended in 2003 to encapsulate them as a scoping function so it inherently hides (scopes) its local (private) variables:
var MyModule = function() {
var internalFunc = function (params) {
// ...
}
function apiFunc1 () {
// ...
}
function apiFunc2 (params) {
// ...
}
return { // The public part
apiFunc1: apiFunc1,
someApi: apiFunc2 // Change name
}
}
var myModule = MyModule()
result1 = myModule.apiFunc1()
result = myModule.internalFunc() // Does not exist
There are a few variations of this, such as:
- inlining functions’ code directly in the returned public object:
return {
apiFunc1: function () { ... },
someApi: function (params) { ... }
} - an anonymous closure
var myModule = function() { ... } ()
which invokes the function just after declaring it. This warrants the module instance as a singleton. - module export: instantiating the returned module object first, then populating it:
var module = {}
module.apiFunc1 = apiFunc1()
return module
This pattern was implemented in a number of JavaScript frameworks, like Dojo, Sencha’s ExtJS, JQuery and notably Yahoo’s YUI library by Eric Miraglia.
Dependencies
Now what if such a module depends on other things, i.e. needs to import other modules? Just provide them as parameters to the scoping function:
var MyModule = function(dep1, dep2) {
var internalFunc = function (params) {
return dep1(params).dep2()
}
// ...
}
var myModule = MyModule(dep1, dep2)
However, all of this was synchronous, which didn’t suit well the browser environment, where you don’t want to block rendering.
AMD
In order to handle the loading of dependencies in a dynamic and asynchronous way, Asynchronous Module Definition was proposed a few years later. It got traction on the front end, with RequireJS (initially RunJS, created in 2009 by James Burke, a Dojo developer) being the most popular implementation of it, and this looked like this:
define(
["dependency1", "dependency2"],
function (dep1, dep2) { // Injection
function privateFunction() {
// ...
}
function apiFunc1() {
dep1()
}
function apiFunc2() {
dep2()
privateFunction()
}
return {
apiFunc1: apiFunc1
someApi: apiFunc2
}
}
)
Basically, the AMD define()
API allows you to declare your module with the following parameters:
- the names (as strings) of its dependencies
- a callback function which will receive those dependencies, once loaded.
This is an API that Angular 1.x developers will find familiar, as it was used for the initial module system of this framework.
CommonJS
On the back end, CommonJS was an effort to provide a module system to JavaScript servers. It used a style you may be familiar with if you’re written anything for NodeJS (which used a slight variant) a few years ago.
You didn’t see it when developing your module but at runtime, under the hood, CommonJS loaders wrapped any module code into a function (thus providing isolation through the function scope), like this:
function (exports, require, module, __filename, __dirname) {
// code of your module
}
This is why variables/functions such as require()
, exports
, module
, __dirname
, etc. are magically known in any module code:
var dep1 = require("dep1")
var dep2 = require("dep2")
function internalFunc() {}
function apiFunc1() {
dep1(__dirname)
}
function apiFunc2() {
dep2()
}
module.exports = {
apiFunc1: apiFunc1,
someApi: apiFunc2,
}
But there was more to it: for the first time, it allowed an isomorphic module API between the back and the front ends, thanks to Browserify, a tool which converted NodeJS modules into browser-compliant (ES) modules.
UMD = uglify (AMD + CommonJS)
Things were starting to become complex though, since two systems, AMD and CJS, were competing as the reference module system for JavaScript. As an attempt to abstract those differences, the Unified Module Definition was invented. It was implemented as an Immediately Invoked Function Expression (IIFE) which could handle both:
(function (root, factory) {
if (typeof define === "function" && define.amd) {
// AMD
define(["jquery", "underscore"], factory);
} else if (typeof exports === "object") {
// Node, CommonJS-like
module.exports = factory(require("jquery"), require("underscore"));
} else {
// Browser globals (root is window)
root.returnExports = factory(root.jQuery, root._);
}
})(this, function ($, _) {
// methods
function a() {} // private because it's not returned (see below)
function b() {} // public because it's returned
function c() {} // public because it's returned
// exposed public methods
return {
b: b,
c: c,
};
});
Of course, while this worked, not many module authors would agreed to write their module this way. Even when generated, this introduced a third module format to choose among.
ES Modules
Standards came at the rescue with EcmaScript Modules (ESM). We had to wait for ES2015 (a.k.a. ES6 or “JavaScript 6”) to have them land officially, so SystemJS showed up as a kind of polyfill to provide them in the interim, from the existing other module formats.
ESM use the following syntax:
import something from "someModule"
or, from browser’s HTML:
<script type="module" src="someModule.mjs"></script>
or, from a package.json
:
{
"name": "my-module",
"type": "module",
"main": "someModule.js",
"exports": {
"something": "./SomeThing.js"
}
}
At this time, we started to see some light at the end of the tunnel. But still, we had to make our way out of it, now copying with four formats of modules:
- Authors still feared to loose users if they didn’t provide their module in every possible format, because adoption was still fragmented.
- Users struggled to find the right format to use, and honestly most of them didn’t understand much about them and their differences. They just wanted to use a library and “make it work”.
Node
The above package.json
definitions is typical from NodeJS, the reference standalone/server engine for JavaScript (now with competitors such as deno then bun). Node historically used the CommonJS module system, with ESM becoming a more and more popular standard, it had to find a way to:
- load ESM modules
- resolve .
js
files as either CommonJS modules or ESM.
It started to support it in three ways:
- by using the
.mjs
file extension to denote ESM module, starting experimentally in 2018 (in its version 8.5) - by specifying
"type": "module"
inpackage.json
(starting with version 12) to load.js
files as ESM modules (through a command-line flag until 12.17). As for any module, you can then define what its"exports"
. - by specifying the
--input-type=module
as a CLI argument.
The last two options being overridden if you use a .cjs
extension.
FESM
However resolving a chain of ESM imports can be tricky, and you’d want to avoid doing this at runtime. To avoid impairing loading performance, Angular 4+ devised Flat ESM (FESM), which basically merged/flattened the sum of static imports into a single file to help tree-shaking, help reduce the size of your generated bundles, and speed up build, transpilation, and loading in the browser in certain scenarios.
Dynamic imports
While ESM was gaining momentum, ES2020 (ES11) brought a further sophistication which both boosted it and raised new (async) issues: the ability to load a module dynamically, at runtime. This would allow conditional loading, such as:
if (secure) { // Must be the case in prod
crypto = window.crypto
console.log("Secure env, using window.crypto=", crypto)
} else {
const { Crypto: PeculiarCrypto } = await import("@peculiar/webcrypto")
crypto = new PeculiarCrypto()
console.log("Non-secure env, using PeculiarCrypto=", crypto)
}
or lazy loading, such as:
edit: async (data) => {
const { EditPage } = await import("edit/EditPage")
editPage = new EditPage(data)
}
In browsers, this implies that modules code is fetched though HTTP calls. JS app builders could compile such calls and relevant chunks to be loaded, but would have a harder guess devising which file chunk to prepare for variable imports or, should I say, imports with variables:
async loadMessages(lang) {
const { default: msg } = await import(`./i18n/messages_${lang}`)
this.messages = msg
}
To fine tune such loading, you’ll want to fallback with code using constant values, if they are known:
async loadMessages(lang) {
switch (lang) {
case 'fr':
const { default: msg } = await import(`./i18n/messages_fr`)
this.messages = msg
break;
case 'en':
default:
const { default: msg } = await import(`./i18n/messages_en`)
this.messages = msg
}
}
TypeScript
As you probably know, TypeScript is just a different way to write JavaScript, since its “compiler” converts the .ts
code into JavaScript code according a TS configuration. TS modules look the same as ESM. For example:
import dep1 from "dep1"
function apiFunc1() {
dep1()
}
export default const = {
apiFunc1: apiFunc1
}
Nothing much different from what standard JavaScript can do today. However, using the old CJS require()
remains allowed:
var dep2 = require("dep2")
And indeed, CommonJS remaining popular for some time, most developers used to configure their tsconfig.json
to transpile those imports back to this CJS require()
call format.
This was configured using two properties of the "compilerOptions"
of tsconfig.json
:
moduleResolution
: Aside the historicalclassic
resolution mode, you’ll use eithernode
for CommonJS ornode16
/nodeNext
for ESM since TypeScript 4.7.module
: the compilation format for the modules, eitherCommonJS
or ESM (ES2015
,ES2020, ES2022, ESNext
... but alsoNode16
,NodeNext
…)
Depending on what you choose for that latest module
property, this will result in:
- Generating
require()
calls for each of yourimport
s if you specify"CommonJS"
. This will produce, for instance:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.twoPi = void 0;
const constants_1 = require("./constants");
exports.twoPi = constants_1.valueOfPi * 2;
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.twoPi = void 0;
const constants_1 = require("./constants");
exports.twoPi = constants_1.valueOfPi * 2;
- Generating (keeping) native
import
statements if you specify"ES2015"
,"ES2020"
(or"ESNext"
). In such a case, make sure specify"type": "module"
in yourpackage.json
to denote that you root module is an ESM (and so will be able to issue sub-imports).
Absolute paths
By default, paths of import modules are relative to the importing one. This can be unconvenient for the reader when displaying only a partial path, or sometimes when you refactor and move files, depending on the cleverness of your IDE.
To avoid this, TypeScript extensions such as tsconfig-paths can help, through the path mapping TS capability, to use arbitrary module paths (that doesn’t start with “/” or “.”).
Execution
Specifying the format of modules to be generated is not enough for NodeJS to recognize them as such at runtime: you have to tell it which module loader it should use to run to execute your transpiled code properly:
- if you run CommonJS code that uses
require()
, you can use any of:ts-node
CLI,node -r ts-node/register
,NODE_OPTIONS="ts-node/register" node
orrequire('ts-node').register({/* options */})
- if you run ESM code (experimental), you must use:
node --loader ts-node/esm
orNODE_OPTIONS="--loader ts-node/esm" node
.
Stylesheets
The import
ESM/TS statement is actually not limited to JavaScript code modules. You can actually import any file, provided the MIME type is expected. A typical need aside .js
files is the load of .json
, .html
or .css
files (in order to lazy-load style with components). For instance:
import myComponentStyle from './myComponent.css'
As the imported contents will typically result in module objects in memory, even if not JS-originated, these would be called JSON modules, “HTML modules” or CSS modules. This is also why you might want to follow the mystyle.module.css
naming convention.
This can be done natively, or through tools.
Native custom import
By default this will not be understood by browsers, which assume imports to deliver code…
Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/css".
…unless you specify the proper import attributes:
import myComponentStyle from './myComponent.css' with { type: "css" }
However this syntax is not supported by Firefox, so you might want to use a safer tool transpilation for that.
Imports transpilation
Delegating imports parsing to tools can actually provide more benefits than support on most platforms. It can also bring support for more formats (.scss
, raw text…) and behaviors (such as resolving sub-imports or not).
Such tools can be:
- Webpack, which has plugins to load
.scss
for instance; - a modern build tool like ViteJS, which supports a number of import flavors: you can import images URLs, etc.
- the TypeScript compiler (
tsc
), provided you declared it in your global types declaration file (Globals.d.ts
):
declare module "*.module.css"
declare module "*.module.scss"
Note that those tools could also help for CSS @import
s.
Tools
Build
Build tools aim to optimize your code for:
- production by bundling your modules in an optimized way (through merging and minification). Examples are Rollup.
- development by providing quick Hot Module Replacement (HMR). Examples are ViteJS (which uses Rollup under the hood)
Those tools do a great job allowing to import virtually anything from a JS/TS file: other code, but also CSS or any other file (HTML or whatever) actually, just by looking at import statements in your sources.
Circular dependencies
As soon as you have dependencies (through imports), you may create circular dependencies and sometimes load failures. Some tools such as madge or the even better dpdm will help you debug this.
Publication
Your own modules can be published in a package registry. This can be the default npmjs, cloud registries (GCP Artifact registry, AWS Code Artifact, etc.) or even your own private registry (Verdaccio for instance).
Conclusion
Modules have been a mess in JavaScript, with authors packaging up to four different formats to make sure to match every users contraints.
Hopefully, the ESM standard has reached a large adoption, thus simplifying and boosting capabilities of modules in modern apps. So don’t feed the hell and keep stopping using and building other module formats: use ESM everywhere you can, and only that.