NodeJS by Example: ES Modules

ES Modules (ESM) are the official JavaScript module system. Node.js fully supports ESM, providing modern import/export syntax.

Basic Imports Import from Node.js built-in modules using node: prefix.

import fs from 'node:fs';
import path from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';

Named Exports Export multiple values from a module. In math.js: export const PI = 3.14159; export function add(a, b) { return a + b; } export function multiply(a, b) { return a * b; } Import named exports import { PI, add, multiply } from './math.js';



Default Exports Export a single main value from a module. In logger.js: export default class Logger { log(msg) { console.log(msg); } } Import default export import Logger from './logger.js';



Mixed Exports Combine default and named exports. In utils.js: export default function main() {} export const helper1 = () => {}; export const helper2 = () => {}; Import both import main, { helper1, helper2 } from './utils.js';



Renaming Imports Use 'as' to rename imports and avoid conflicts. import { helper as utilHelper } from './utils.js';

import { readFile as read, writeFile as write } from 'node:fs/promises';

Namespace Imports Import all exports as a single object. import * as utils from './utils.js';

import * as fsPromises from 'node:fs/promises';

console.log('Available fs methods:', Object.keys(fsPromises).slice(0, 5));

Re-exporting Export from another module. In index.js (barrel file): export { add, multiply } from './math.js'; export { default as Logger } from './logger.js'; export * from './utils.js';


Dynamic Imports Import modules at runtime with import(). Conditional loading

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

async function loadOptionalFeature() {
  if (process.env.ENABLE_FEATURE) {
    const { feature } = await import('./optional-feature.js');
    return feature;
  }
  return null;
}

import.meta Access module metadata. Convert URL to path

console.log('Current file URL:', import.meta.url);
console.log('Current directory:', import.meta.dirname);
console.log('Current filename:', import.meta.filename);

import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

console.log('__filename:', __filename);
console.log('__dirname:', __dirname);

import.meta.resolve() Resolve module specifiers to URLs. Resolve relative paths const localModule = import.meta.resolve('./local.js');

const fsPath = import.meta.resolve('node:fs');
console.log('Resolved fs path:', fsPath);

Top-Level Await Use await at the module's top level. Fetch config at startup const config = await readFile('./config.json', 'utf8') .then(JSON.parse) .catch(() => ({})); Wait for async initialization await database.connect();




const startTime = await Promise.resolve(Date.now());
console.log('Module loaded at:', new Date(startTime).toISOString());

JSON Imports Import JSON files with import assertions. import pkg from './package.json' with { type: 'json' }; console.log('Package name:', pkg.name);

Conditional Exports (package.json) Define different entry points for different conditions.

/*
{
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "types": "./dist/types/index.d.ts"
    },
    "./utils": {
      "import": "./dist/esm/utils.js",
      "require": "./dist/cjs/utils.js"
    }
  }
}
*/

Subpath Exports Export specific subpaths from a package. Usage: import { feature } from 'my-package/feature';

/*
{
  "exports": {
    ".": "./index.js",
    "./feature": "./src/feature.js",
    "./utils/*": "./src/utils/*.js"
  }
}
*/

Module Resolution How Node.js finds modules. 1. Built-in modules: node:fs, node:path 2. Absolute paths: /home/user/module.js 3. Relative paths: ./module.js, ../module.js 4. Package names: lodash, express

ESM vs CommonJS Differences Key differences to be aware of. ESM: - Static imports (hoisted, analyzed at parse time) - this is undefined at top level - No __filename, __dirname (use import.meta) - import/export syntax - .js extension required for relative imports CommonJS: - Dynamic requires (can be conditional) - this refers to module.exports - __filename, __dirname available - require/module.exports syntax - Extension optional



Interoperability with CommonJS Import CommonJS modules from ESM. CJS module exports: module.exports = { hello: 'world' }; module.exports = function() {}; Import in ESM: import pkg from 'cjs-package'; // default import import { named } from 'cjs-package'; // may work if CJS exports object



Creating Dual Packages (ESM + CJS) Support both module systems.

/*
{
  "name": "my-package",
  "type": "module",
  "main": "./dist/cjs/index.cjs",
  "module": "./dist/esm/index.js",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs"
    }
  }
}
*/

Practical Example: Module Factory Create configurable module exports. Export the factory Or export a configured instance

function createAPI(config = {}) {
  const { baseURL = 'https://api.example.com', timeout = 5000 } = config;
  
  return {
    baseURL,
    timeout,
    
    async get(endpoint) {
      console.log(`GET ${baseURL}${endpoint}`);
      // Implementation here
    },
    
    async post(endpoint, data) {
      console.log(`POST ${baseURL}${endpoint}`, data);
      // Implementation here
    }
  };
}

export { createAPI };

export const api = createAPI({ baseURL: 'https://myapi.com' });

Practical Example: Plugin System Dynamic plugin loading with ESM. Usage: const plugins = await loadPlugins([ './plugins/auth.js', './plugins/logging.js' ]);

async function loadPlugins(pluginPaths) {
  const plugins = [];
  
  for (const pluginPath of pluginPaths) {
    try {
      const plugin = await import(pluginPath);
      
      if (typeof plugin.default?.init === 'function') {
        await plugin.default.init();
        plugins.push(plugin.default);
        console.log(`Loaded plugin: ${pluginPath}`);
      }
    } catch (err) {
      console.error(`Failed to load plugin ${pluginPath}:`, err.message);
    }
  }
  
  return plugins;
}

Practical Example: Lazy Loading Load heavy modules only when needed.

let heavyModule = null;

export async function processLargeData(data) {
  // Lazy load the heavy module
  if (!heavyModule) {
    console.log('Loading heavy module...');
    heavyModule = await import('node:zlib');
  }
  
  // Use the module
  return new Promise((resolve, reject) => {
    heavyModule.gzip(Buffer.from(JSON.stringify(data)), (err, result) => {
      if (err) reject(err);
      else resolve(result);
    });
  });
}

Best Practices 1. Always use .js extension for relative imports 2. Use node: prefix for built-in modules 3. Prefer named exports for better tree-shaking 4. Use barrel files (index.js) to simplify imports 5. Set "type": "module" in package.json for ESM 6. Use dynamic imports for optional/conditional modules 7. Leverage top-level await for async initialization


Run the ES Modules examples Run a single ESM file without package.json Check if running in ESM mode package.json settings for ESM: Use .mjs extension for ESM files in CJS projects Use .cjs extension for CJS files in ESM projects

$ node esm-modules.js

$ node --experimental-default-type=module script.js

$ node -e "console.log(typeof require)"
# undefined (ESM mode)
# function (CommonJS mode)

# {
#   "type": "module"
# }

$ node script.mjs

$ node script.cjs