ES Modules in Node

Difficulty: Beginner

ES Modules (ESM) are the official JavaScript module standard defined in the ECMAScript specification. While CommonJS was Node.js's original module system, ES Modules are the future of JavaScript modules and work in both browsers and Node.js. Node.js has supported ES Modules since version 12 (behind a flag) and fully since version 14. Understanding both systems and their interoperability is essential for modern Node.js development.

There are two ways to enable ES Modules in Node.js. The first is to use the .mjs file extension instead of .js. Any file ending in .mjs is treated as an ES Module regardless of any other configuration. The second is to add "type": "module" to the nearest package.json file. When this field is set, all .js files in that package are treated as ES Modules. If you need a CommonJS file in a "type": "module" package, use the .cjs extension. The package.json approach is the most common in modern projects.

ES Modules use import and export statements instead of require and module.exports. Named exports allow you to export multiple values: 'export function add() {}' or 'export const PI = 3.14'. A module can also have a single default export: 'export default class Calculator {}'. When importing, named exports require curly braces: 'import { add, PI } from "./math.js"', while default exports do not: 'import Calculator from "./calc.js"'. Unlike CommonJS, import statements are static and must be at the top level of the file (not inside if blocks or functions).

There are several key differences between ESM and CJS that you must understand. ES Modules are loaded asynchronously while CommonJS is synchronous. ESM uses static analysis (imports are resolved before execution) while CJS is dynamic (require can be called conditionally at runtime). In ESM, __dirname and __filename are NOT available; instead you use import.meta.url. ESM files must always include the file extension in import paths: 'import { x } from "./file.js"' (not './file'). ESM exports are live bindings (changes to exported values are reflected in importers) while CJS exports are value copies.

Interoperability between ESM and CJS modules is possible but has some rough edges. An ES Module can import a CommonJS module using a default import: 'import express from "express"'. However, a CommonJS module cannot use 'require' to load an ES Module synchronously. Instead, you must use dynamic import: 'const mod = await import("./esm-module.mjs")'. This asymmetry is because ESM loading is asynchronous. Most popular npm packages now support both module systems through conditional exports in package.json.

Code examples

Named and Default Exports

// --- math.mjs (or math.js with "type": "module" in package.json) ---

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

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

export const PI = 3.14159;

// Default export
export default class Calculator {
  constructor() {
    this.history = [];
  }
  compute(a, op, b) {
    const ops = { '+': add, '*': multiply };
    const result = ops[op](a, b);
    this.history.push(`${a} ${op} ${b} = ${result}`);
    return result;
  }
}

// --- app.mjs ---
import Calculator, { add, multiply, PI } from './math.mjs';

console.log(add(2, 3));
console.log(multiply(4, 5));
console.log('PI:', PI);

const calc = new Calculator();
console.log(calc.compute(10, '+', 5));
console.log(calc.history);

Named exports are imported with curly braces. The default export is imported without braces and can be given any name. A module can have multiple named exports but only one default export.

Replacing __dirname and __filename in ESM

// In CommonJS:
// console.log(__dirname);
// console.log(__filename);

// In ES Modules, use import.meta.url:
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

console.log('Current file:', __filename);
console.log('Current dir:', __dirname);

// Building paths relative to the current file
const configPath = join(__dirname, 'config', 'settings.json');
console.log('Config path:', configPath);

// import.meta.url gives a file:// URL
console.log('Raw URL:', import.meta.url);

import.meta.url provides the file URL of the current module. Use fileURLToPath to convert it to a regular path. This two-line pattern replaces __dirname and __filename in ES Modules.

Dynamic Imports

// Dynamic import works in both ESM and CJS
// Returns a Promise, so you can use it with await

async function loadModule(moduleName) {
  try {
    const mod = await import(moduleName);
    return mod;
  } catch (err) {
    console.log(`Failed to load ${moduleName}:`, err.message);
    return null;
  }
}

// Conditional loading
async function main() {
  const os = await import('os');
  console.log('Platform:', os.platform());
  console.log('CPUs:', os.cpus().length);

  // Dynamic import is the only way to load ESM from CJS
  // and to do conditional/lazy loading in ESM
  const moduleName = 'path';
  const pathMod = await import(moduleName);
  console.log('Separator:', pathMod.sep);
}

main();

Dynamic import() returns a Promise and works everywhere. It is the only way to import ES Modules from CommonJS code and enables code splitting and lazy loading patterns.

Key points

Concepts covered

import/export, .mjs Extension, package.json type Field, Named Exports, Default Exports, Interop