Home/Learn/JavaScript & TypeScript/Modules — import, export and the CommonJS Split

Modules — import, export and the CommonJS Split

Intermediate
Language Core

ES Modules are the standard: static import/export, resolved before execution, with live bindings. CommonJS require() is the older Node system, and the two do not mix cleanly — which is the source of most "cannot use import outside a module" errors.

Overview

For most of its life JavaScript had no module system, so the ecosystem invented several. Node standardised on CommonJS — require() and module.exports — and browsers eventually got ES Modules, which are now the standard everywhere. ESM imports are static: the engine reads them before running any code, which is what makes tree-shaking and circular-dependency detection possible. Understanding which system a file is in, and why a package can break when you mix them, saves hours of confusing errors.

Named and Default Exports

Named exports are the default choice: they are explicit, renameable at the import site, and survive refactoring better because the name is checked.

Named exports vs default
// utils.js — named exports
export const formatPrice = paise => `₹${(paise / 100).toFixed(2)}`
export function slugify(s) { ... }
export { internalName as publicName }

// importing
import { formatPrice, slugify } from './utils.js'
import { formatPrice as money } from './utils.js'
import * as utils from './utils.js'

// default — one per module, name chosen by the importer
export default function Button(props) { ... }
import Button from './Button.jsx'
import AnythingAtAll from './Button.jsx'   // legal, and a downside

Static Analysis and Live Bindings

Imports are hoisted and resolved before execution, and they are live views of the exported binding rather than copies.

Live bindings and dynamic import
// Imports hoist — this works:
greet()
import { greet } from './greet.js'

// Bindings are live, not snapshots:
// counter.js
export let count = 0
export const bump = () => count++

// main.js
import { count, bump } from './counter.js'
console.log(count)   // 0
bump()
console.log(count)   // 1  — the import sees the update

// Dynamic import when you need it at runtime — returns a promise
const { default: Chart } = await import('./Chart.js')

ESM vs CommonJS in Node

This is the practical friction. Node decides which system a file uses by extension and by package.json.

The interop rules that cause the errors
// CommonJS — synchronous, dynamic, older
const express = require('express')
module.exports = { handler }

// ESM — static, async, standard
import express from 'express'
export { handler }

// Which one Node uses:
//   .mjs                        -> always ESM
//   .cjs                        -> always CommonJS
//   .js + "type": "module"      -> ESM
//   .js + no type field         -> CommonJS

// ESM can import CommonJS:
import express from 'express'         // works
// CommonJS cannot require ESM:
const mod = require('./esm-only.js')  // ERR_REQUIRE_ESM

Key Points to Remember

  • 1ES Modules are static — imports are resolved before execution, which enables tree-shaking
  • 2Prefer named exports: they are explicit, checked, and refactor better than defaults
  • 3Imported bindings are live views of the export, not copies taken at import time
  • 4Node picks ESM or CommonJS by file extension and package.json "type" — this is what causes ERR_REQUIRE_ESM
  • 5ESM can import CommonJS but not the reverse; use dynamic import() to load a module at runtime

Interview Questions

Sign in to ask Aria
1

What is the difference between a named export and a default export?

Easy
2

Why can ES Modules be tree-shaken when CommonJS generally cannot?

Medium
3

You get "ERR_REQUIRE_ESM" in a Node project. What has happened and how do you fix it?

Medium

Ask Aria about Modules — import, export and the CommonJS Split

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…