🚀 JavaScript modules (aka ESM)

JavaScript modules were introduced in ES6 in 2015 as a solution to standardize the zoo of module systems on all platforms that already existed at that time.

To create a JavaScript module, just export the public functionality to be used by other modules with export directive:

To use a module, import its elements in the namespace of another module:

This syntactic form for importing modules is a static declaration: it only accepts a string literal as the module specifier and introduces bindings into the local scope via a pre-runtime “linking” process.

So while it’s possible to destructure CJS imports, but not possible with ESM:

As JavaScript modules designed to be statically analyzable and there’s no way for the runtime to know in advance what value will be given to the exported symbol without running a program first — this is the reason why export/import directives should be used only at the top-level of the file.

The static nature of the import and export directive allows static analyzers to build a full tree of dependencies without running code, it also enables important use cases such as bundling tools and tree-shaking, which is very cool.

JavaScript modules are asynchronous by nature because they can be processed in three separate phases.

Construction — fetching and parsing files to generate Module Records. Instantiating — building a static modules graph, wiring up exports/imports in memory. Evaluating — loading the code in Javascript runtime.

In NodeJS the modules should be created in the files with .mjs extension, which signals that it’s a module rather than a regular script defined with familiar .js extension. This decision was criticized many times but probably is the most optimal at the moment. On the other hand, browsers do not really care about the extensions as long as the files are included with a script element having a special attribute <script type="module" src="esm-index.js"></script> and served with a correct MIME types like text/javascript for JavaScript files.

Since there are no special attributes or MIME types in NodeJS, it was decided to use .mjs extension to determine whether it’s a module or a regular script .

But why is it so important?

There are differences between a script and a module in the specification:

Modules have strict mode enabled by default (as if they have a “use strict” implicitly at the top). The static export and import directives are only available within modules and won’t work in regular scripts. Modules are evaluated only once, while classic scripts as many times as included in the DOM.

Module on the web have extra distinctive characteristics:

Modules have a lexical top-level scope. This means that for example, running var foo = 42; within a module does not create a global variable named foo , accessible through window.foo in a browser, although that would be the case in a classic script. Modules are fetched with CORS. Any cross-origin module scripts must be served with the proper headers, such as Access-Control-Allow-Origin: *

Because of these differences, the JavaScript engine could behave differently with modules and classic scripts and thus requires to understand what is where.

So as mentioned before, browsers can treat <script> tags as modules by setting a type attribute:

What is remarkable here is that if the modern browser understands module type, it will ignore a nomodule script tag, while legacy browser will use the fallback script instead! It means you don’t have to transpile your esm-index.mjs file anymore if it uses ES6 features, that were introduced at the same time with modules like window.fetch , Classes , arrow functions , or async/await which will make your bundle size smaller. 🤘

We already mentioned that in contrast with require of CommonJS, import is not dynamic (for example it cannot be called conditionally) because exports of JavaScript modules are defined lexically. That is, the symbols exported by modules are determined when the JavaScript code is being parsed and before it is actually executed. But there is a way out!

JavaScript modules: dynamic import()

In some cases, it’s useful to import a module conditionally or on-demand, compose module specifier at a runtime or import a module from a script file. None of these are possible with static import.

Unlike the lexical import , dynamic import() function is processed at the time or evaluation (like CJS require ).

The import() returns a Promise for the module namespace object of the requested module, which is created after construction, instantiating, and evaluating all of the module’s dependencies, as well as the module itself.

Since import() returns a promise, it’s possible to use async/await instead: