Modules

Difficulty: Intermediate

JavaScript modules are a way to organize code into separate files where each file has its own scope. Before ES6 modules, JavaScript had no native module system, and developers relied on patterns like IIFEs, AMD, and CommonJS (Node.js) to achieve encapsulation. ES Modules (ESM) became the standard, providing static import and export syntax that enables tree-shaking, static analysis, and better tooling support.

Named exports allow you to export multiple values from a module. Each export is identified by its name, and importing code must use the same name (or rename with 'as'). You can export variables, functions, classes, or any binding either inline with the declaration or in a collected export statement at the bottom of the file. Named exports are ideal when a module provides multiple utilities or components.

Default exports allow a module to export one primary value. Each module can have only one default export, and the importing code can give it any name. Default exports are commonly used for the main class or function of a module, like a React component or an Express router. You can mix default and named exports in the same module, though this is less common.

The 'as' keyword allows renaming during both export and import. This resolves naming conflicts when importing from multiple modules that export identically named values. Re-exporting with 'export { x } from ./module' lets you create barrel files (index.js) that aggregate exports from multiple files into a single import point, simplifying the public API of a package.

Dynamic import() returns a promise that resolves to the module object, enabling code splitting and lazy loading. Unlike static imports which must be at the top level, dynamic imports can appear anywhere - inside functions, conditionals, or event handlers. This is essential for performance optimization in large applications, loading features on demand rather than upfront.

ES Modules differ from CommonJS in several fundamental ways. ESM uses import/export while CJS uses require/module.exports. ESM imports are statically analyzed at parse time, enabling tree-shaking, while CJS is evaluated at runtime. ESM creates live bindings (importing a value reflects changes made in the exporting module), while CJS copies values. ESM files run in strict mode automatically, while CJS does not. In Node.js, you can use ESM with .mjs extensions or by setting "type": "module" in package.json.

Code examples

Named exports and imports

// --- math.js ---
// Inline named exports
export const PI = 3.14159;
export const E = 2.71828;

export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

// --- app.js ---
// Import specific names
import { PI, add, multiply } from './math.js';

console.log(`PI = ${PI}`);
console.log(`2 + 3 = ${add(2, 3)}`);
console.log(`4 * 5 = ${multiply(4, 5)}`);

// Import all as namespace
import * as MathUtils from './math.js';
console.log(`E = ${MathUtils.E}`);

// Rename on import
import { add as sum } from './math.js';
console.log(`Renamed: ${sum(10, 20)}`);

Named exports require curly braces on import and must match the exported name (unless renamed with 'as'). The namespace import (import * as) groups all exports under a single object.

Default exports and mixing with named exports

// --- Logger.js ---
class Logger {
  constructor(prefix) {
    this.prefix = prefix;
  }
  
  log(message) {
    console.log(`[${this.prefix}] ${message}`);
  }
  
  error(message) {
    console.error(`[${this.prefix} ERROR] ${message}`);
  }
}

// Default export
export default Logger;

// Named exports alongside default
export const LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
export function createLogger(prefix) {
  return new Logger(prefix);
}

// --- app.js ---
// Default import (any name works)
import AppLogger from './Logger.js';
// Or combine default and named
import Logger, { LOG_LEVELS, createLogger } from './Logger.js';

const logger = new Logger('APP');
logger.log('Application started');
console.log('Levels:', LOG_LEVELS);

const dbLogger = createLogger('DB');
dbLogger.log('Connected');

A module's default export is imported without curly braces and can be given any name. When combining default and named imports, the default comes first, followed by named imports in curly braces.

Re-exporting and barrel files

// --- utils/string.js ---
export function capitalize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}
export function slugify(str) {
  return str.toLowerCase().replace(/\s+/g, '-');
}

// --- utils/array.js ---
export function unique(arr) {
  return [...new Set(arr)];
}
export function chunk(arr, size) {
  const chunks = [];
  for (let i = 0; i < arr.length; i += size) {
    chunks.push(arr.slice(i, i + size));
  }
  return chunks;
}

// --- utils/index.js (barrel file) ---
export { capitalize, slugify } from './string.js';
export { unique, chunk } from './array.js';
// Can also rename: export { capitalize as cap } from './string.js';

// --- app.js ---
// Single clean import from the barrel
import { capitalize, unique, chunk } from './utils/index.js';

console.log(capitalize('hello'));
console.log(unique([1, 2, 2, 3, 3]));
console.log(chunk([1, 2, 3, 4, 5], 2));

Barrel files (index.js) re-export from multiple modules, creating a single entry point. Instead of importing from multiple paths, consumers import everything from one location. This pattern is common in component libraries and utility packages.

Dynamic import and CommonJS vs ESM

// Dynamic import - loads module on demand
async function loadFeature(featureName) {
  try {
    const module = await import(`./features/${featureName}.js`);
    module.init();
    console.log(`Feature '${featureName}' loaded`);
  } catch (error) {
    console.log(`Feature '${featureName}' not available`);
  }
}

// Conditional loading
const isAdmin = true;
if (isAdmin) {
  // Only loaded when needed
  // const { AdminPanel } = await import('./AdminPanel.js');
  console.log('Admin module would load here');
}

// CommonJS vs ES Modules comparison:
// ---- CommonJS (Node.js traditional) ----
// const fs = require('fs');           // synchronous
// module.exports = { myFunc };        // single export object
// exports.helper = helperFunc;        // named export shorthand

// ---- ES Modules ----
// import fs from 'fs';               // static, hoisted
// export default myFunc;             // default export
// export { helper };                 // named export

// Key differences:
console.log('ESM: static analysis, tree-shaking, live bindings');
console.log('CJS: runtime evaluation, value copying, dynamic');
console.log('ESM: always strict mode');
console.log('CJS: non-strict by default');

Dynamic import() returns a promise, enabling lazy loading. The key differences between ESM and CJS affect bundling, tree-shaking, and runtime behavior. Most modern projects use ESM, but understanding CJS is essential for Node.js compatibility.

Key points

Concepts covered

named exports, default exports, import/export, dynamic import, module scope, CommonJS, ES Modules