JavaScript Modules
Modules help you split large code into small focused files, avoid naming clashes, and reuse logic cleanly. If you work on real projects or in teams, this is one of the most important habits to learn early.
JavaScript projects commonly use two module systems. ES modules (ESM) use import and export. ESM is the standard modern JavaScript module system and works in modern browsers and Node.js. CommonJS uses require() and module.exports; it remains important when working with existing Node.js code and projects that use it.
| Concern | ES modules | CommonJS |
|---|---|---|
| Export | export { add } or export default add | module.exports = { add } |
| Import | import { add } from "./math.js" | const { add } = require("./math") |
| Typical environment | Browsers and modern Node.js | Older Node.js code and some tooling |
| Loading model | Static imports help tools analyze dependencies. | require() is traditionally evaluated at runtime. |
A bundler such as Vite, Webpack, Parcel, or Rollup follows module imports, combines or splits files, and prepares optimized assets for production. Modern bundlers can use the static structure of ES module imports and exports to identify unused code and remove it during production builds. This process is called tree-shaking.
// math.js
export function add(first, second) {
return first + second;
}
// main.js
import { add } from "./math.js";
console.log(add(2, 3));
// math.js
function add(first, second) {
return first + second;
}
module.exports = { add };
// main.js
const { add } = require("./math");
A circular dependency happens when module A imports module B while module B imports module A. ESM supports live bindings, but one module may read an export before the other module has finished initializing it. CommonJS may expose a partially initialized export object.
// user.js
import { formatUser } from "./format.js";
export const user = formatUser("Sam");
// format.js
import { user } from "./user.js";
export function formatUser(name) {
return `${name} (${user?.role ?? "guest"})`;
}
Prefer a third module containing shared constants or functions, or move the shared operation behind a function call so initialization order is simpler. Circular imports are not always wrong, but they are a warning sign when exports are needed immediately at module load time.
Debugging clue: errors such as “cannot access before initialization” or unexpectedundefined exports can point to an import cycle.
A module is a JavaScript file used as a self-contained unit of code. When a file is loaded as an ES module, it has its own module scope, and code is shared with other modules only through explicit export and import statements. This supports better organisation and reusable code without placing every variable in the global scope.
Without modules, as your codebase grows, you end up with:
- One massive file with thousands of lines
- Variables that accidentally overwrite each other
- Code that is nearly impossible to test or maintain
// math.js - a module that handles math functions
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// main.js - import and use those functions
import { add, multiply } from "./math.js";
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
The math.js file exports two functions. The main.js file imports and uses them. Each file only knows what it needs to know. Clean and simple.
In a browser, load the entry file with <script type="module" src="./main.js"></script>. Serve the page through a local web server during development; opening an HTML file directly with file:// can prevent the browser from loading module imports.
When you first start coding, it is easy to put everything in one file. That works fine for small projects. But as soon as your app grows, having everything in one place becomes a problem.
Modules solve several real problems:
- Organisation: Each file has a clear purpose - one for user logic, one for API calls, one for utilities
- Reusability: Write a function once, import it anywhere you need it
- No naming conflicts: Variables in one module do not leak into another
- Easier to test: Small, focused modules are much easier to test in isolation
- Team-friendly: Different developers can work on different files without stepping on each other
// Without modules - everything in one file, messy
let appName = "TaskBoard";
let appVersion = 3;
function formatApp(name, version) {
return `${name} (v${version})`;
}
function fetchConfig(id) { /* ... */ }
function saveConfig(config) { /* ... */ }
function deleteConfig(id) { /* ... */ }
function validateEmail(email) { /* ... */ }
function validatePassword(pwd) { /* ... */ }
// hundreds more functions fill this file
// With modules - clean and organised
// user.js -> handles user data
// api.js -> handles server requests
// validate.js -> handles form validation
// main.js -> ties everything together
A named export lets you export multiple things from a file. You put the export keyword in front of each thing you want to share. The name must match exactly when you import it.
Named imports use curly braces, and the local name normally matches the exported name. You can rename an import with as when a different local name is clearer or avoids a conflict.
// utils.js
export const PI = 3.14159;
export function greet(name) {
return `Hello, ${name}!`;
}
export function square(n) {
return n * n;
}
You can also export everything at the bottom of the file as a group, which some developers prefer for readability:
// utils.js - exporting at the bottom
const PI = 3.14159;
function greet(name) {
return `Hello, ${name}!`;
}
function square(n) {
return n * n;
}
export { PI, greet, square };
A default export is used when a module has one main thing to share. You use the export default keyword, and each module can have only one default export. A default import does not use curly braces, and the importing file can choose its local name.
// user.js
export default class User {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
return `Hi, I am ${this.name}`;
}
}
You can also default-export a function or a plain value:
// greet.js - default export of a function
export default function greet(name) {
return `Hello, ${name}!`;
}
// config.js - default export of an object
const config = {
apiUrl: "https://api.example.com",
timeout: 5000
};
export default config;
To import named exports, use curly braces {} and the exact name of the thing you exported. The name must match - spelled and cased exactly the same way.
// utils.js
export const PI = 3.14159;
export function greet(name) { return `Hello, ${name}!`; }
export function square(n) { return n * n; }
// main.js
import { PI, greet, square } from "./utils.js";
console.log(PI); // 3.14159
console.log(greet("World")); // Hello, World!
console.log(square(4)); // 16
You do not have to import everything. Only import what you need:
// Only import greet - skip PI and square
import { greet } from "./utils.js";
console.log(greet("Developer")); // Hello, Developer!
You can also rename an import using the as keyword. This is useful when two modules export something with the same name:
import { greet as sayHello } from "./utils.js";
console.log(sayHello("Developer")); // Hello, Developer!
To import a default export, you do not use curly braces. You just choose any name you want - it does not have to match the name used in the other file.
// user.js
export default class User {
constructor(name) {
this.name = name;
}
greet() {
return `Hi, I am ${this.name}`;
}
}
// main.js - you can name it anything
import User from "./user.js";
// or: import MyUser from "./user.js";
// or: import Person from "./user.js";
const admin = new User("Admin");
console.log(admin.greet()); // Hi, I am Admin
You can import both a default export and named exports from the same file in one statement:
// shapes.js
export default function Circle(r) {
return Math.PI * r * r;
}
export function square(n) { return n * n; }
export function triangle(b, h) { return 0.5 * b * h; }
// main.js
import Circle, { square, triangle } from "./shapes.js";
console.log(Circle(5)); // 78.53981633974483
console.log(square(4)); // 16
console.log(triangle(6, 8)); // 24
- A module is a JavaScript file used as an ES module, with code shared explicitly through export and import
- Modules have their own scope - variables are private unless exported
- Named exports: use export keyword, imported with curly braces {}
- Default export: use export default, each file can only have one, imported without braces
- Named imports must use the exact same name, or rename with as
- Default imports can use any name you choose
- You can combine default and named imports in one statement
- To use modules in a browser, use <script type="module">
What's next? Now that you know how to split code into modules, let's move on to OOP and Classes in the next tutorial.
- What is a JavaScript module?
- What is the difference between named exports and default exports?
- How many default exports can a file have?
- How do you import a named export?
- Can you rename an import? How?
- What happens to variables inside a module - are they global?
- What is the as keyword used for in imports?
- Can a file have both named and default exports?
- How do you use modules in a browser (what attribute do you need on the script tag)?
- What is tree-shaking and how do modules help with it?