ES modules

ESM, or ECMAScript Modules, is the official module system introduced in ECMAScript 6 (ES6), also known as ECMAScript 2015. ESM provides a native, standardized way to organize and structure code in JavaScript applications. Unlike CommonJS, which is synchronous and primarily used in Node.js, ESM is designed to work in both browser and server-side environments.

Asynchronous Loading

One of the significant differences from CommonJS is that ESM is designed for asynchronous loading. This makes it suitable for modern, performance-oriented web development, where asynchronous loading is preferred to avoid blocking the main thread.

Browser and Node.js Support

ESM is supported natively in modern browsers and in recent versions of Node.js. This native support simplifies the development process, eliminating the need for transpilers or bundlers in many cases.

Static Analysis

ESM supports static analysis, meaning that dependencies are determined at compile time rather than runtime. This allows tools to optimize the loading of modules.

Encapsulation

Everything inside an ES6 module is private by default, and runs in strict mode (there’s no need for 'use strict').

Circular Dependencies

ESM handles circular dependencies more effectively than CommonJS. Circular dependencies occur when two or more modules depend on each other.

Syntax for JavaScript and TypeScript is the same:

// Exporting a variable or function
export const myVariable = "Hello, ESM!";

// Exporting a class
export class MyClass { /*...*/ }

// Exporting a function
export function myFunction() { /*...*/ }

Importing

// Importing a named export
import { myVariable, myFunction } from './myModule';

// Importing a default export
import MyDefaultExport from './myModule';

More syntax variations of import.

import defaultExport from "module-name";
import * as name from "module-name";
import { export1 } from "module-name";
import { export1 as alias1 } from "module-name";
import { default as alias } from "module-name";
import { export1, export2 } from "module-name";
import { export1, export2 as alias2, /* … */ } from "module-name";
import { "string name" as alias } from "module-name";
import defaultExport, { export1, /* … */ } from "module-name";
import defaultExport, * as name from "module-name";
import "module-name";

Dynamic Imports

  • ESM introduces the import() function for dynamic imports. This allows you to load modules asynchronously, improving performance by loading only the necessary modules when they are needed.
// Dynamic import
import('./myModule').then((module) => {
  // Use the module
});

Usage in browsers

ESM is considered the modern standard for JavaScript modules, and its native support in both browsers and Node.js makes it a versatile and powerful choice for organizing and structuring code in contemporary JavaScript applications.

In order to use the import declaration in a source file, the file must be interpreted by the runtime as a module.

When you are using ECMAScript Modules (ESM) in a browser environment, you need to specify the type="module" attribute in the script tag to indicate that the script should be treated as an ECMAScript module. This is necessary because the default script type is assumed to be JavaScript (non-module).

<!-- Using ESM in the browser -->
<script type="module" src="main.js"></script>

When you are using ECMAScript Modules (ESM) in a browser environment, the server should serve the module files with the MIME type application/javascript. This ensures that the browser correctly interprets the file as JavaScript code.

Here’s an example of how the server might set the MIME type for JavaScript files:

Content-Type: application/javascript

There is a difference in how regular <script> tags and ECMAScript Modules (ESM) handle cross-origin requests and adhere to the Same-Origin Policy.

Regular <script> Tags:

  • Traditional <script> tags do not enforce the Same-Origin Policy. They can fetch scripts from other domains without restrictions. This behavior is known as script-src cross-origin loading.
<!-- Regular script tag fetching a script from another domain --> <script src="https://example.com/script.js"></script>
  • This lack of restriction for regular <script> tags can pose security risks, as it allows scripts from external domains to be executed on a web page without proper control.

ECMAScript Modules (ESM):

  • ECMAScript Modules, on the other hand, are subject to the Same-Origin Policy. When you use the import statement to fetch a module, the browser enforces CORS (Cross-Origin Resource Sharing) policies.
  • If the module is on a different domain, the server hosting the module needs to include the appropriate CORS headers to allow the cross-origin request. Without proper CORS headers, the browser will block the request.
<!-- Module script tag fetching a module from another domain -->
<script type="module" src="https://example.com/module.js"></script>

This requires CORS to be set correctly on the server. The following example CORS header allows any origin to access the resource:

Access-Control-Allow-Origin: *

Modules won’t send cookies or other header credentials unless a crossorigin="use-credentials" attribute is added to the <script> tag and the response contains the header Access-Control-Allow-Credentials: true.

ES modules in Node.js

In Node.js, you can use ECMAScript Modules (ESM) by following one of the methods:

You can specify that your project is using ESM by adding a “type” field with the value “module” to your package.json file. With this configuration, all .js files in your project are treated as ESM.

// package.json
{
  "type": "module",
  "main": "main.js"
}

Alternatively, you can use the .mjs file extension for your ESM files. Files with this extension are treated as ESM by default.

// main.mjs
import { myFunction } from './myModule';

myFunction();

When running a script with Node.js, you can explicitly specify the input type as “module” using the --input-type flag. This is useful if you want to run a specific script as an ESM module without modifying the package.json or file extensions.

node --input-type=module myScript.js

The “type”: “module” approach in package.json is commonly used as it provides a project-wide setting, while the .mjs extension is useful for individual files. The --input-type flag allows for on-the-fly specification when running scripts.

Common errors

The following error indicates that you are attempting to use the require() statement to import an ECMAScript Module (ESM) in a context where CommonJS is expected. This error typically occurs when there is a mix of module types in your Node.js project, so if you are using ESM in your project (e.g., by setting "type": "module" in package.json or using .mjs file extension), you should consistently use import and export syntax throughout your codebase.

(node:26444) [ERR_REQUIRE_ESM] Error Plugin: xyz [ERR_REQUIRE_ESM]: require() of ES Module C:\code\xyz\node_modules\somepackage\lib\somepackage.js from C:\code\xyz\dist\index.js not supported.
Instead change the require of somepackage.js in C:\code\xyz\dist\index.js to a dynamic import() which is available in all CommonJS modules.

Here’s an example of how this error might occur:

// somepackage.js - ESM module
export const someFunction = () => {
  console.log('This is an ESM module.');
};

Next, we make the mistake of using require to import ESM module in CommonJS module:

// index.js - CommonJS module
const somePackage = require('somepackage'); // This line would trigger the error

somePackage.someFunction();

To fix this either ensure that your entire codebase uses the same module type: Either convert your project to CommonJS by removing "type": "module" from package.json (or renaming .mjs files to .js), or convert the entire project to ESM by ensuring that all files use import and export syntax.

If you want to keep the mixed module types, you can use dynamic import to load the ESM module:

// index.js - CommonJS module
const somePackage = await import('somepackage');

somePackage.someFunction();

Error: Cannot use import statement outside a module

Cannot use import statement outside a module

This error mainly occurs when you use the import keyword to import a module in a file that Node.js does not recognize as an ECMAScript module. Or when you omit the type="module" attribute in a script tag.

  • If your project is intended to use ESM but lacks the "type": "module" field in package.json, Node.js will treat files with .js extensions as CommonJS by default.
  • To use ESM, either change the extension to .mjs or set "type": "module".

CommonJS vs. ECMAScript modules

CommonJS and ES6 modules cannot be mixed together, because apart from the different syntax for importing and exporting, CommonJS and ES6 modules execute code at different times when code is being imported: ES6 modules are pre-parsed in order to resolve further imports before code is executed. CommonJS modules load dependencies on demand while executing the code.

// CommonJS modules

// ---------------------------------
// one.js
console.log('running one.js');
const hello = require('./two.js');
console.log(hello);

// ---------------------------------
// two.js
console.log('running two.js');
module.exports = 'Hello from two.js';
running one.js
running two.js
hello from two.js

But with ES2015

// ES2015 modules

// ---------------------------------
// one.js
console.log('running one.js');
import { hello } from './two.js';
console.log(hello);

// ---------------------------------
// two.js
console.log('running two.js');
export const hello = 'Hello from two.js';
running two.js
running one.js
hello from two.js

ESM is natively supported by Node.js 12 and later.

ES modules in TypeScript

To set up a TypeScript project to use ECMAScript Modules (ESM), you need to configure TypeScript to generate code compatible with ESM and also ensure that Node.js recognizes your project as an ESM project. Here are the steps to set up a TypeScript project with ESM:

npm install -g typescript
npm install --save-dev typescript ts-node
// tsconfig.json
{
  "compilerOptions": {
    "module": "ESNext",
    "target": "ESNext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "strict": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules"]
}
  • "module": "ESNext": Specifies that TypeScript should generate ESM code.
  • "target": "ESNext": Specifies the ECMAScript version that TypeScript will generate.
  • "moduleResolution": "node": Enables Node.js module resolution.

Create a simple TypeScript file in a src directory:

// src/main.ts
export const greet = (name: string): string => {
  return `Hello, ${name}!`;
};

console.log(greet('World'));

Run TypeScript Code using ts-node with npm start:

// package.json
{
  "scripts": {
    "start": "ts-node src/main.ts"
  }
}

If you are using Node.js directly to run your code, ensure to use:

node --experimental-specifier-resolution=node dist/main.js

TypeScript compilerOptions and modules

compilerOption esModuleInterop explained

"esModuleInterop" : "true": addresses situations where you are using both ESM and CommonJS modules in the same project and need to seamlessly import and use modules from both systems. If set to true, you can use import directly, and TypeScript will handle the necessary adjustments:

// ESM with esModuleInterop enabled
import myModule from './myModule';

If set to false, you may need to use the more verbose syntax for importing CommonJS modules with default exports

// ESM without esModuleInterop
import * as myModule from './myModule';
const defaultValue = myModule.default;

esModuleInterop is especially useful when you are working with existing code or third-party libraries that are written in CommonJS but you want to use them in a project that primarily uses ESM.

compilerOption module explained

module specifies what JS code tsc will emit:

If the input source code is:

import { createSourceFile } from "typescript"

then module: “commonjs” will emit

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const typescript_1 = require("typescript");

and module: “esnext” will emit

import { createSourceFile } from "typescript"

You cannot write imports in the style of import fs = require("fs") under --module es2015 (or higher ES targets) because there is no require to speak of in the ES module system. 

Additionally, using top-level await is only allowed in --module es2022 (or higher) or system because it requires corresponding support in the module loading system.

Specifying module: “nodenext” implies that moduleResolution is set to nodenext (a new resolution mode designed for Node’s specific implementation of co-existing ESM and CJS).

compilerOption moduleResolution explained

This defines the algorithm that answers the question where and how to look for a module. Looking in a folder “node_modules” is related to Node but also many other non-Node code has adopted this location for convenience, but is is not going to be correct for every runtime.

Using nodenext requires that your file endings are either mts or mjs for ESM and cts or cjs for CommonJS.

Choosing between CommonJS and ESM for Node.js projects

Both module systems have their advantages and trade-offs, and the choice often depends on factors like project requirements, ecosystem compatibility, and personal or team preferences. Here are some reasons why you might opt for CommonJS:

  1. Ecosystem Compatibility:
    • The Node.js ecosystem has traditionally used CommonJS modules, and many existing npm packages are written in CommonJS. If your project heavily depends on existing npm modules that are not yet converted to ESM or are not compatible with ESM, you might choose to stick with CommonJS.
  2. Tooling and Libraries:
    • Some tools and libraries in the Node.js ecosystem are designed with CommonJS in mind. If you rely on specific tools or libraries that work seamlessly with CommonJS, it might be easier to start your project with CommonJS to avoid potential compatibility issues.
  3. Incremental Adoption:
    • If you are working on an existing project or transitioning from an older version of Node.js that primarily uses CommonJS, you might choose to adopt ESM gradually. Starting with CommonJS allows for an incremental transition, enabling you to update individual modules to ESM as needed.
  4. Maturity and Stability:
    • CommonJS has been a stable and widely used module system in Node.js for many years. It has a mature ecosystem, and developers are familiar with its conventions. If stability and a well-established ecosystem are crucial for your project, starting with CommonJS might be a pragmatic choice.
  5. Simplicity and Familiarity:
    • CommonJS syntax is simpler and might be more familiar to developers who have been working with Node.js for an extended period. If your team values simplicity and ease of use, and you don’t need the advanced features introduced by ESM, CommonJS might be a straightforward choice.
  6. Tooling Support:
    • While tooling support for ESM is improving, CommonJS is still more widely supported by various development tools, including bundlers, transpilers, and testing frameworks. If you rely on a specific set of tools that work well with CommonJS, it can influence your choice.

It’s essential to note that Node.js itself fully supports both CommonJS and ESM, and the choice between them often depends on project-specific considerations. Additionally, Node.js continues to evolve, and the ecosystem is gradually adapting to embrace ESM more widely. When starting a new project, it’s a good idea to evaluate the specific requirements and constraints and choose the module system that aligns best with your needs.

TS2354: This syntax requires an imported helper but module  tslib  cannot be found.

I came across this error when using async / await in ts file module and tsconfig.json like this:

  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "esnext",

tslib is commonly used to support certain JavaScript features, including the transformation of async/await code.

Try adding:

npm install tslib

And this ensures that TypeScript includes the necessary library for supporting async/await:

// tsconfig.json
{
  "compilerOptions": {
    "lib": ["esnext", "other-libraries"],
    // other options...
  },
  // other settings...
}

Alternatively, the error disappears after I switched to

  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "nodenext",

About Author

Mathias Bothe To my job profile

I am Mathias from Heidelberg, Germany. I am a passionate IT freelancer with 15+ years experience in programming, especially in developing web based applications for companies that range from small startups to the big players out there. I create Bosycom and initiated several software projects.