For a long time JavaScript had no native module system. Scripts were included in order with several <script> tags, hoping global variables would not collide, or external solutions such as CommonJS in Node.js were used. Today the language has its own ES modules (ECMAScript modules), supported by every modern browser and by Node.js. Understanding them well helps you write tidy code and make better use of build tools.
What a module is
A module is a file with its own scope: variables declared inside it do not become global. The module explicitly decides what to expose with export, and other files decide what to use with import.
// maths.js
export const PI = 3.14159;
export function circleArea(radius) {
return PI * radius * radius;
}
function internalHelper() {
// not exported: invisible from outside
}
// main.js
import { circleArea } from './maths.js';
console.log(circleArea(2));
Named exports and default export
There are two forms of export.
Named exports: a module can have as many as it likes, and importers use the same names, in curly braces. They can be renamed with as.
import { circleArea as area, PI } from './maths.js';
Default export: at most one per module, imported without braces and under whatever name you prefer.
// formatDate.js
export default function formatDate(date) {
return date.toLocaleDateString('en-GB');
}
// elsewhere
import format from './formatDate.js';
Many teams prefer to use named exports only: names stay consistent across the project, editors autocomplete imports better and code search is more reliable. It is a convention, not a rule of the language.
You can also import everything as an object, with import * as maths from './maths.js', or re-export from an index file: export { circleArea } from './maths.js'.
Important characteristics of modules
- Automatic strict mode: every module runs in
strict modewithout needing to declare it. - Evaluated only once: if several files import the same module, its code runs once and they all share the same instance. This is useful for configuration and shared state, but it is worth keeping in mind.
- Live bindings: an import is not a copy of the value but a reference to the export. If the source module updates an exported variable, importers see the new value. You cannot, however, reassign an import from outside.
- Static structure:
importandexportstatements sit at the top level of the file and cannot be inside conditions. This very staticness allows build tools to analyse dependencies and remove unused code (tree shaking).
Modules in the browser
To load a module directly in the browser, all you need is the type="module" attribute:
<script type="module" src="/js/main.js"></script>
Some differences compared with classic scripts:
- modules are deferred by default: they run after the HTML has been parsed, as with
defer; - import paths must be full or relative URLs, with the extension:
./utils.js, notutils; - modules are loaded following CORS rules, so opening the HTML file directly from disk (
file://) usually does not work: you need a small local server; thisat the top level isundefined, not the global object.
For very old browsers there was the nomodule attribute, which loads a script only where modules are not supported; today it is rarely needed.
Import maps
In the browser an import such as import { format } from 'date-fns' does not work on its own, because the browser does not know where date-fns is. Import maps let you map short names to URLs:
<script type="importmap">
{
"imports": {
"utils/": "/js/utils/",
"charts": "/vendor/charts.esm.js"
}
}
</script>
<script type="module">
import { draw } from 'charts';
import { format } from 'utils/format.js';
</script>
For small projects, import maps and native modules can entirely replace a build tool.
Dynamic import
The import() function loads a module on demand and returns a promise. It is the tool for splitting code and downloading heavy features only when they are needed:
exportButton.addEventListener('click', async () => {
const { exportPdf } = await import('./pdf-export.js');
exportPdf(doc);
});
Anyone who never clicks the button never downloads that code. Build tools recognise import() and automatically create a separate file.
Modules also support top-level await, useful for example to load a configuration before continuing. Use it sparingly, because it blocks the execution of modules that depend on the current one.
ES modules and CommonJS
In Node.js two systems coexist: CommonJS (require and module.exports) and ES modules. The main differences:
| Aspect | CommonJS | ES modules |
|---|---|---|
| Syntax | require(), module.exports |
import, export |
| Loading | synchronous | asynchronous |
| Static analysis | limited | complete |
| In the browser | no, without tools | yes, natively |
In Node.js a file is treated as an ES module if it has the .mjs extension or if package.json contains "type": "module". For new projects, ES modules are the choice to prefer: they are the language standard and work in both environments.
In short
- A module has its own scope and exposes only what it declares with
export. - Named exports keep names consistent; there is at most one default export per file.
- In the browser they are used with
type="module", are deferred and need paths with extensions. - Import maps map short names to URLs;
import()loads code only when needed. - For new projects, prefer ES modules to CommonJS in Node.js too.