Understanding ES6 Modules

This article explores ES6 modules, showing how they can be used today with the help of a transpiler.

Almost every language has a concept of modules — a way to include functionality declared in one file within another. Typically, a developer creates an encapsulated library of code responsible for handling related tasks. That library can be referenced by applications or other modules.

The benefits:

  1. Code can be split into smaller files of self-contained functionality.
  2. The same modules can be shared across any number of applications.
  3. Ideally, modules need never be examined by another developer, because they’ve has been proven to work.
  4. Code referencing a module understands it’s a dependency. If the module file is changed or moved, the problem is immediately obvious.
  5. Module code (usually) helps eradicate naming conflicts. Function x() in module1 cannot clash with function x() in module2. Options such as namespacing are employed so calls become module1.x() and module2.x().

Where are Modules in JavaScript?

Anyone starting web development a few years ago would have been shocked to discover there was no concept of modules in JavaScript. It was impossible to directly reference or include one JavaScript file in another. Developers therefore resorted to alternative options.

Multiple HTML <script> Tags

HTML can load any number JavaScript files using multiple <script> tags:

<script src="lib1.js"></script>
<script src="lib2.js"></script>
<script src="core.js"></script>
console.log('inline code');

The average web page in 2018 uses 25 separate scripts, yet it’s not a practical solution:

  • Each script initiates a new HTTP request, which affects page performance. HTTP/2 alleviates the issue to some extent, but it doesn’t help scripts referenced on other domains such as a CDN.
  • Every script halts further processing while it’s run.
  • Dependency management is a manual process. In the code above, if lib1.js referenced code in lib2.js, the code would fail because it had not been loaded. That could break further JavaScript processing.
  • Functions can override others unless appropriate module patterns are used. Early JavaScript libraries were notorious for using global function names or overriding native methods.

Script Concatenation

One solution to problems of multiple <script> tags is to concatenate all JavaScript files into a single, large file. This solves some performance and dependency management issues, but it could incur a manual build and testing step.

Module Loaders

Systems such as RequireJS and SystemJS provide a library for loading and namespacing other JavaScript libraries at runtime. Modules are loaded using Ajax methods when required. The systems help, but could become complicated for larger code bases or sites adding standard <script> tags into the mix.

Module Bundlers, Preprocessors and Transpilers

Bundlers introduce a compile step so JavaScript code is generated at build time. Code is processed to include dependencies and produce a single ES5 cross-browser compatible concatenated file. Popular options include Babel, Browserify, webpack and more general task runners such as Grunt and Gulp.

A JavaScript build process requires some effort, but there are benefits:

  • Processing is automated so there’s less chance of human error.
  • Further processing can lint code, remove debugging commands, minify the resulting file, etc.
  • Transpiling allows you to use alternative syntaxes such as TypeScript or CoffeeScript.

ES6 Modules

The options above introduced a variety of competing module definition formats. Widely-adopted syntaxes included:

  • CommonJS — the module.exports and require syntax used in Node.js
  • Asynchronous Module Definition (AMD)
  • Universal Module Definition (UMD).

A single, native module standard was therefore proposed in ES6 (ES2015).

Everything inside an ES6 module is private by default, and runs in strict mode (there’s no need for 'use strict'). Public variables, functions and classes are exposed using export. For example:

// lib.js
export const PI = 3.1415926;

export function sum(...args) {
  log('sum', args);
  return args.reduce((num, tot) => tot + num);

export function mult(...args) {
  log('mult', args);
  return args.reduce((num, tot) => tot * num);

// private function
function log(...msg) {

Alternatively, a single export statement can be used. For example:

// lib.js
const PI = 3.1415926;

function sum(...args) {
  log('sum', args);
  return args.reduce((num, tot) => tot + num);

function mult(...args) {
  log('mult', args);
  return args.reduce((num, tot) => tot * num);

// private function
function log(...msg) {

export { PI, sum, mult };

import is then used to pull items from a module into another script or module:

// main.js
import { sum } from './lib.js';

console.log( sum(1,2,3,4) ); // 10

In this case, lib.js is in the same folder as main.js. Absolute file references (starting with /), relative file references (starting ./ or ../) or full URLs can be used.

Multiple items can be imported at one time:

import { sum, mult } from './lib.js';

console.log( sum(1,2,3,4) );  // 10
console.log( mult(1,2,3,4) ); // 24

and imports can be aliased to resolve naming collisions:

import { sum as addAll, mult as multiplyAll } from './lib.js';

console.log( addAll(1,2,3,4) );      // 10
console.log( multiplyAll(1,2,3,4) ); // 24

Finally, all public items can be imported by providing a namespace:

import * as lib from './lib.js';

console.log( lib.PI );            // 3.1415926
console.log( lib.add(1,2,3,4) );  // 10
console.log( lib.mult(1,2,3,4) ); // 24

Continue reading %Understanding ES6 Modules%