How to import a module using ES modules in Node.js

How to import a module using ES modules in Node.js

Node.js originally embraced CommonJS modules, but ES modules (ESM) have now become a first-class citizen with a syntax that aligns perfectly with modern JavaScript standards.

At its core, ES modules use import and export statements. Unlike CommonJS’s require() and module.exports, ES modules are statically analyzable. This allows for better optimization and makes the tooling ecosystem richer.

Here’s the fundamental syntax to export a value from a module:

export const myValue = 42;

export function myFunction() {
  return "This is ES module syntax!";
}

And to consume what another file exports, you use the import statement like so:

import { myValue, myFunction } from './myModule.js';

console.log(myValue);         // Outputs: 42
console.log(myFunction());    // Outputs: That is ES module syntax!

Note that ES modules require you to use explicit file extensions like .js in imports—something that trips up many coming from CommonJS background where the extension is optional.

Using the default keyword lets you export a single “main” value from a module. This is the counterpart to the CommonJS default object export.

export default function greet(name) {
  return Hello, ${name}!;
}

To import a default export, the syntax is different compared to named imports:

import greet from './greet.js';

console.log(greet('Michael'));  // Outputs: Hello, Michael!

One subtlety worth calling out is that ES modules operate in strict mode implicitly, so no need to write "use strict";. This means your variables must be declared properly and some older sloppy behaviors are disallowed automatically.

Another important difference lies in how ES modules are loaded. They are asynchronous by nature. That means you cannot conditionally use import statements anywhere in your code, unlike require(). However, dynamic imports exist and provide a promise-based way to import modules on-the-fly.

async function loadModule() {
  const module = await import('./dynamicModule.js');
  module.doSomething();
}

This pattern gives you more granular control over loading dependencies and can optimize startup performance or reduce initial bundle size in complex applications.

Ultimately, mastering these basics is important because they form the foundation for cleaner and more future-proof Node.js code.

Moving on to how Node.js treats files when dealing with ES modules, the precise understanding of file extensions and configuration through package.json makes all the difference when it comes to smooth module resolution and avoiding obscure errors.

Handling module file extensions and the package.json configuration

Node.js differentiates between CommonJS and ES modules primarily based on file extensions and the type field in package.json. By default, files with the .js extension are treated as CommonJS unless specified otherwise. To opt into ES modules without renaming files, you explicitly tell Node.js how to interpret your code via package.json.

Setting "type": "module" in your package.json changes the module system interpretation for all .js files within that package root and its subdirectories, marking them as ES modules:

{
  "name": "my-package",
  "version": "1.0.0",
  "type": "module"
}

With this setting, you can write ES module syntax directly in .js files without renaming them to .mjs. Otherwise, to use ES modules without specifying type, you must rename files with the .mjs extension.

The .mjs extension stands for “module JavaScript” and was introduced to explicitly mark files as ES modules in Node.js. Unlike most web bundlers or browsers that infer module type from or file context, Node requires either the type field or the .mjs to confirm module type.

This extension-based mechanism exists because Node.js must support both module systems side by side for historical reasons. Mixing the two can cause cryptic errors such as ERR_REQUIRE_ESM or SyntaxError: Cannot use import statement outside a module.

The following patterns are valid for ES modules in Node.js:

// Using "type":"module" in package.json
import fs from 'fs';
import { myUtil } from './util.js';

// Using explicit .mjs extension without package.json type
import fs from 'fs';
import { myUtil } from './util.mjs';

Note the explicit file extensions in relative imports. If you forget them, Node throws an error like:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module './util'

Also, when mixing module systems inside a project, avoid importing CommonJS modules directly via import if they use named exports. Instead, import the CommonJS module’s entire export as the default import:

import pkg from './commonjsModule.cjs';
const { namedExport } = pkg;

Here, CommonJS files conventionally use the .cjs extension to explicitly mark their module system when type is set to module in package.json. This distinction allows Node to parse and execute files correctly, preventing internal conflicts.

Dynamic import also respects these rules, so the module specifier in import() must include extensions and point to the right file type, or you’ll face resolution errors:

const mod = await import('./dynamicModule.mjs');

To sum up how Node resolves modules:

  • .js files are CommonJS by default unless type: "module" is defined.
  • .mjs files are always treated as ES modules.
  • .cjs files are always treated as CommonJS.
  • package.json’s type applies recursively to all files underneath the package root.
  • Explicit extensions are mandatory when importing relative paths in ES modules.

Grasping these details prevents subtle bugs caused by Node’s dual module system support and prepares your codebase to fully embrace modern JavaScript standards.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *