ES Modules
A Small Story š
Meet Rohit. He runs a small tiffin service in Pune called "Ghar Ka Khana" š±.
In the beginning, Rohit did everything himself: cooking, packing, taking orders on the phone, collecting money, and delivering on his scooter. All his notes were written in one big notebook. Recipes, customer phone numbers, bills, and delivery addresses were all mixed together on the same pages.
It worked when he had 10 customers. But when he reached 500 customers, things fell apart. šµ
- He wrote a new recipe called "special", and it overwrote a customer's note that was also called "special".
- Finding one customer's address meant flipping through 200 pages.
- When he hired a helper, she accidentally changed the bill amounts while updating a recipe.
So Rohit made a big change. He split the work into departments, each with its own notebook:
- š³ Kitchen ā only recipes
- š Orders ā only customer orders
- š° Billing ā only payments
- šµ Delivery ā only addresses
Each department keeps its notes private, and shares only what others need. The kitchen tells delivery "Order #45 is ready." It doesn't hand over its whole recipe book!
That's exactly what ES modules do for JavaScript code. šÆ
What Are ES Modules?
š Definition: An ES module is a JavaScript file that keeps its code private by default, and chooses what to share with other files using
export. Other files bring in that shared code usingimport.
"ES" stands for ECMAScript, JavaScript's official rulebook. ES modules were added in ES6 (2015), and they are now the official, standard way to organise JavaScript code.
Three key ideas:
| Idea | Meaning |
|---|---|
| One file = one module | Each file is its own module |
| Private by default | Variables and functions stay inside their file unless exported |
| Explicit sharing | Use export to share and import to use |
š” Module examples need more than one file, so most code in this lesson is shown as normal code blocks, not in the playground.
Why Do We Need Modules?
Without modules, every script on a web page shares one big global space. It's like everyone in a joint family writing on the same whiteboard in the hall. š
Problem 1: Name clashes š„
<script src="cart.js"></script> <!-- defines: let total = 500; -->
<script src="coupon.js"></script> <!-- also defines: let total = 50; ā -->
Two files used the same name total. One breaks the other (or the page throws an error).
Problem 2: Load order headaches š
<script src="app.js"></script> <!-- uses formatPrice() ā not loaded yet! -->
<script src="utils.js"></script> <!-- defines formatPrice() -->
If the files are in the wrong order, things break. With 50 script tags, keeping the order right is a nightmare.
Problem 3: Hidden dependencies šµļø
Looking at app.js, you can't tell which other files it needs. You have to search through the whole project.
Problem 4: Nothing is private š
Any file can read or change any variable from any other file, even by accident.
ā How modules fix all of this
| Problem | Module solution |
|---|---|
| Name clashes | Each module has its own scope |
| Load order | import tells JavaScript exactly what to load first |
| Hidden dependencies | The import lines at the top list everything a file needs |
| Nothing is private | Only exported things can be used by other files |
The Journey: How JavaScript Modules Evolved šŗļø
JavaScript didn't have modules for its first 20 years! Developers kept inventing their own solutions. Each one fixed some problems, and brought new ones.
Let's walk through each stage and see what changed.
1995 ā Global scripts (everything shared, chaos)
2000s ā Namespace objects (one global object per project)
2000s ā IIFE / Module pattern (real privacy with functions)
2009 ā CommonJS (modules for Node.js servers)
2011 ā AMD (async modules for browsers)
2013 ā UMD (one format that works everywhere)
2012+ ā Bundlers (combine files for the browser)
2015 ā ES Modules ā (the official standard!)
Stage 1: Global Scripts (1995) š
How it worked: Every file added its variables to the global scope.
// utils.js
var taxRate = 0.05;
function addTax(amount) {
return amount + amount * taxRate;
}
// app.js
console.log(addTax(100)); // works, but only if utils.js loaded first
| ā What it gave | ā Problems |
|---|---|
| Very simple | Name clashes everywhere |
| Load order must be manually correct | |
| No privacy at all |
Stage 2: Namespace Objects (early 2000s) š¦
What changed: Instead of many globals, put everything inside one big object.
| ā What changed | ā Still a problem |
|---|---|
| Fewer global names, so fewer clashes | Still no privacy: anyone can change taxRate |
| Code grouped by feature | Load order still manual |
Stage 3: IIFE and the Module Pattern (2000s) š
What changed: Developers used a trick called an IIFE (Immediately Invoked Function Expression), a function that runs right away. Variables inside a function are private, so this finally gave real privacy!
This was called the Module Pattern, and jQuery-era code used it everywhere.
| ā What changed | ā Still a problem |
|---|---|
| Real privacy for the first time | Still needed one global per module |
| Clear "public" part (the returned object) | Load order still manual |
| Dependencies still hidden |
Stage 4: CommonJS (2009) š„ļø
What changed: When Node.js brought JavaScript to servers, it needed a real module system. It used CommonJS, with require() and module.exports.
// š billing.js
const taxRate = 0.05; // private
function addTax(amount) {
return amount + amount * taxRate;
}
module.exports = { addTax };
// š app.js
const { addTax } = require("./billing");
console.log(addTax(200)); // 210
| ā What changed | ā Problems |
|---|---|
| Every file is its own module automatically | Loads files synchronously (one by one, waiting) |
Dependencies are clearly listed with require() | Fine on a server, but too slow for browsers over the internet |
| No more manual load order | Not part of the JavaScript standard |
š” CommonJS is still very common in older Node.js projects. You'll see
require()a lot!
Stage 5: AMD (2011) š
What changed: AMD (Asynchronous Module Definition), made popular by RequireJS, was designed for browsers. It loads modules asynchronously, without blocking the page.
define(["./billing"], function (billing) {
console.log(billing.addTax(200));
});
| ā What changed | ā Problems |
|---|---|
| Async loading, good for browsers | Lots of wrapper code, hard to read |
| Different from CommonJS, so code couldn't be shared easily |
Stage 6: UMD (2013) š
What changed: Now there were two competing systems (CommonJS and AMD). UMD (Universal Module Definition) was a clever wrapper that checked the environment and worked with both, plus plain globals.
(function (root, factory) {
if (typeof define === "function" && define.amd) define([], factory); // AMD
else if (typeof module === "object") module.exports = factory(); // CommonJS
else root.Billing = factory(); // Global
})(this, function () {
return { addTax: (amt) => amt * 1.05 };
});
| ā What changed | ā Problems |
|---|---|
| One file works everywhere | Very confusing boilerplate šµ |
| Great for libraries | Just a workaround, not a real solution |
Stage 7: Bundlers (2012 onwards) š¦
What changed: Tools like Browserify, Webpack, Rollup, and later Vite and esbuild let developers write modules, and then combine them into one (or a few) files that browsers could load.
cart.js ā
coupon.jsāāāāŗ š ļø Bundler āāāŗ bundle.js āāāŗ Browser
utils.js ā
| ā What changed | ā Problems |
|---|---|
| Write modular code, ship one fast file | Needs extra tools and setup |
| Removes unused code (tree shaking) | Browsers still had no built-in modules |
š” Bundlers are still used today, even with ES modules, to make websites load faster.
Stage 8: ES Modules (2015) ā The Official Standard
What changed: Finally, in ES6 (2015), modules became part of the JavaScript language itself, with the import and export keywords. Today they work in all modern browsers and in Node.js.
// š billing.js
const taxRate = 0.05; // š private
export function addTax(amount) {
return amount + amount * taxRate;
}
// š app.js
import { addTax } from "./billing.js";
console.log(addTax(200)); // 210
What ES modules brought:
| ā Improvement | Why it matters |
|---|---|
| Built into the language | No extra library needed |
| Works in browsers and Node.js | One system everywhere š |
| Clean, simple syntax | import / export are easy to read |
| Static structure | Tools can see imports before running code, so they can remove unused code (tree shaking) |
| Async loading | Doesn't block the browser |
| Private by default | Each file has its own scope |
| Strict mode automatically | Catches common mistakes |
The Whole Journey at a Glance
| Stage | Syntax | Privacy | Dependencies clear? | Where it works |
|---|---|---|---|---|
| Global scripts | <script> | ā | ā | Browser |
| Namespace object | App.module = {} | ā | ā | Browser |
| IIFE | (function(){ })() | ā | ā | Browser |
| CommonJS | require / module.exports | ā | ā | Node.js |
| AMD | define([...], fn) | ā | ā | Browser |
| UMD | Big wrapper | ā | ā | Both |
| ES Modules ā | import / export | ā | ā | Both, officially |
Just like Rohit's tiffin service grew from one messy notebook to organised departments, JavaScript grew from global chaos to ES modules. š
How to Use ES Modules
Now let's learn the syntax, step by step.
1. Named Exports (share many things)
Put export in front of anything you want to share.
// š utils/price.js
export const GST_RATE = 0.05;
export function addGst(amount) {
return amount + amount * GST_RATE;
}
export function formatPrice(amount) {
return `ā¹${amount.toFixed(2)}`;
}
You can also export everything at the bottom, in one list:
const GST_RATE = 0.05;
function addGst(amount) { return amount + amount * GST_RATE; }
function formatPrice(amount) { return `ā¹${amount.toFixed(2)}`; }
export { GST_RATE, addGst, formatPrice };
2. Named Imports (use what you need)
// š app.js
import { addGst, formatPrice } from "./utils/price.js";
console.log(formatPrice(addGst(200))); // ā¹210.00
Key points:
- The names inside
{ }must exactly match the exported names. - Import only what you need.
- In browsers, include the file extension:
"./price.js", not"./price".
3. Default Export (share one main thing)
Each file can have one default export, which is its main item.
// š models/Tiffin.js
export default class Tiffin {
constructor(items) {
this.items = items;
}
}
4. Default Import (no curly braces)
// š app.js
import Tiffin from "./models/Tiffin.js"; // any name works
import LunchBox from "./models/Tiffin.js"; // also fine, same class
const lunch = new Tiffin(["Roti", "Dal", "Sabzi"]);
5. Mixing Default and Named
// š orders.js
export default function placeOrder(item) { /* ... */ }
export const MAX_ITEMS = 10;
export function cancelOrder(id) { /* ... */ }
// š app.js
import placeOrder, { MAX_ITEMS, cancelOrder } from "./orders.js";
Named vs Default: Quick Comparison
| Point | Named export | Default export |
|---|---|---|
| How many per file | As many as you like | Only one |
| Export | export const x = 1; | export default x; |
| Import | import { x } from "./a.js"; | import x from "./a.js"; |
| Name when importing | Must match (or rename with as) | Any name |
| Best for | Helpers, constants, many tools | One main class, component, or function |
š” Many teams prefer named exports, because the names stay consistent across the whole project and editors can auto-import them easily.
6. Renaming with as
Rename while importing, for example to avoid clashes:
import { formatPrice as toRupees } from "./utils/price.js";
import { formatPrice as toDollars } from "./utils/usd.js";
console.log(toRupees(99)); // ā¹99.00
Or rename while exporting:
function calc(amount) { /* ... */ }
export { calc as calculateBill };
7. Import Everything (Namespace Import)
Bring in all named exports as one object:
import * as Price from "./utils/price.js";
console.log(Price.GST_RATE); // 0.05
console.log(Price.formatPrice(99)); // ā¹99.00
8. Re-exporting (Barrel Files) š¢ļø
A barrel file collects exports from many files in one place, like a reception desk that directs you to every department.
// š utils/index.js
export { addGst, formatPrice } from "./price.js";
export { formatDate } from "./date.js";
export * from "./validation.js";
export { default as Tiffin } from "../models/Tiffin.js";
// š app.js (one clean import line!)
import { addGst, formatDate, Tiffin } from "./utils/index.js";
9. Import Only for Side Effects
Sometimes you just want a file to run, without importing anything from it, for example to set up styles or add a polyfill.
import "./setup-analytics.js";
Using ES Modules in the Browser š
Add type="module" to the script tag:
<!DOCTYPE html>
<html>
<body>
<h1>Ghar Ka Khana š±</h1>
<script type="module" src="./app.js"></script>
</body>
</html>
You can also write module code directly inside the HTML:
<script type="module">
import { formatPrice } from "./utils/price.js";
console.log(formatPrice(150));
</script>
ā ļø Common gotcha: Opening the HTML file directly (
file:///...) usually won't work. Modules must be served from a web server. Use the Live Server extension in VS Code, or runnpx serve.
Using ES Modules in Node.js š„ļø
Node.js supports ES modules in two ways:
Option 1: Use the .mjs file extension.
billing.mjs
app.mjs
Option 2: Add "type": "module" to package.json (more common ā
).
{
"name": "ghar-ka-khana",
"type": "module"
}
Then all your .js files are treated as ES modules.
ES Modules vs CommonJS
| Point | CommonJS | ES Modules |
|---|---|---|
| Import | const x = require("./x") | import x from "./x.js" |
| Export | module.exports = x | export default x |
| Loading | Synchronous | Asynchronous |
| When imports are read | While the code runs | Before the code runs (static) |
| Official standard | ā No (Node.js only) | ā Yes |
| Works in browsers | ā Not directly | ā Yes |
| Tree shaking | Limited | ā Excellent |
Special Things About Modules āØ
Modules behave a little differently from normal scripts.
1. Each module has its own scope š
// š kitchen.js
const secretRecipe = "Grandma's garam masala"; // private to this file
export const dishName = "Chole";
// š app.js
import { dishName } from "./kitchen.js";
console.log(dishName); // Chole ā
console.log(secretRecipe); // ā ReferenceError: secretRecipe is not defined
2. Strict mode is always on
Modules automatically use strict mode, so mistakes like using an undeclared variable throw errors instead of failing silently.
// inside a module
total = 500; // ā ReferenceError (no let/const/var)
3. A module runs only once
If ten files import the same module, it runs only once, and everyone shares the same copy. It's like a single kitchen serving every department, not ten separate kitchens. š³
// š counter.js
console.log("Counter module loaded!"); // prints only ONCE
export let orders = 0;
export function addOrder() { orders++; }
4. Imports are live and read-only š
An import is a live link to the original variable, not a copy. If the exporting module changes it, you see the update. But you cannot change it from the importing file.
// š app.js
import { orders, addOrder } from "./counter.js";
console.log(orders); // 0
addOrder();
console.log(orders); // 1 ā
(live update)
orders = 10; // ā TypeError: Assignment to constant variable
5. Imports must be at the top level
Normal import statements can't be inside if blocks or functions.
if (isAdmin) {
import { deleteAll } from "./admin.js"; // ā SyntaxError
}
For conditional loading, use dynamic import (next section).
6. Module scripts are deferred
<script type="module"> waits until the HTML has finished loading before it runs, just like <script defer>. So you don't need to put it at the end of the page.
Dynamic Import: Load Modules Only When Needed ā”
Sometimes you don't need a module right away. For example, the payment code is only needed when the user clicks "Pay Now." Why load it on every page visit?
import() loads a module on demand, and returns a Promise.
const payButton = document.querySelector("#pay");
payButton.addEventListener("click", async () => {
const { startPayment } = await import("./payment.js"); // loaded only now!
startPayment(499);
});
It's like calling the delivery boy only when an order is ready, instead of making him wait at the shop all day. šµ
Benefits:
- ā” The first page load is faster
- š¦ Large features load only when used
- š You can load modules conditionally
const language = "hi";
const messages = await import(`./lang/${language}.js`);
Top-Level await ā³
Inside an ES module, you can use await directly, without wrapping it in an async function.
// š config.js
const response = await fetch("/api/settings");
export const settings = await response.json();
Any module that imports config.js will wait until the settings are ready.
ā ļø Top-level
awaitworks only in modules, not in normal scripts.
import.meta: Information About the Current Module š·ļø
import.meta gives you details about the current module, like its URL.
console.log(import.meta.url);
// e.g. "https://gharkakhana.in/js/app.js"
In newer versions of Node.js, you also get import.meta.dirname and import.meta.filename.
A Real Project Structure š
Here's how Rohit's tiffin app might be organised with modules:
ghar-ka-khana/
āāā index.html
āāā js/
āāā app.js ā main entry point
āāā kitchen/
ā āāā recipes.js
ā āāā cook.js
āāā orders/
ā āāā placeOrder.js
ā āāā cancelOrder.js
āāā billing/
ā āāā gst.js
ā āāā invoice.js
āāā utils/
āāā index.js ā barrel file
āāā formatPrice.js
āāā formatDate.js
// š js/app.js
import { cook } from "./kitchen/cook.js";
import { placeOrder } from "./orders/placeOrder.js";
import { createInvoice } from "./billing/invoice.js";
import { formatPrice, formatDate } from "./utils/index.js";
const order = placeOrder("Rajma Chawal", 2);
cook(order);
console.log(createInvoice(order));
Every department has its own file, and app.js clearly shows what it depends on. āØ
Common Mistakes to Avoid
| Mistake | Problem | Fix |
|---|---|---|
Forgetting type="module" | import gives a SyntaxError | <script type="module" src="app.js"> |
Opening HTML with file:// | Modules are blocked (CORS error) | Use a local server (Live Server, npx serve) |
Missing the .js extension in browsers | File not found | import x from "./utils.js" |
Missing ./ for your own files | Treated as a package name | "./utils.js", not "utils.js" |
import { x } for a default export | x is undefined or an error | import x from "..." |
import x for a named export | Wrong value | import { x } from "..." |
Two export default in one file | ā SyntaxError | Only one default per file |
import inside an if block | ā SyntaxError | Use dynamic import() |
| Trying to reassign an imported value | ā TypeError | Change it inside its own module |
Mixing require and import carelessly | Errors in Node.js | Pick one system per project |
Quick Summary Table
| Task | Syntax |
|---|---|
| Named export | export const x = 1; |
| Export a list | export { a, b, c }; |
| Default export | export default App; |
| Named import | import { a, b } from "./file.js"; |
| Default import | import App from "./App.js"; |
| Both together | import App, { a } from "./App.js"; |
| Rename | import { a as b } from "./file.js"; |
| Import everything | import * as Utils from "./utils.js"; |
| Re-export | export { a } from "./file.js"; |
| Side-effect import | import "./setup.js"; |
| Dynamic import | const m = await import("./file.js"); |
| Browser | <script type="module" src="app.js"> |
| Node.js | "type": "module" in package.json |
Key Takeaways
- An ES module is a JavaScript file that keeps its code private by default and shares it using
exportandimport. - Before ES modules, developers used global scripts, namespace objects, IIFEs, CommonJS, AMD, and UMD. Each fixed some problems, but none was an official standard.
- ES modules (2015) became the official standard. They work in browsers and Node.js, have clean syntax, load asynchronously, and support tree shaking.
- Use named exports for many items and a default export for one main item. Rename with
as, import everything with* as, and group exports with barrel files. - Modules have their own scope, run in strict mode, run only once, and support dynamic
import()and top-levelawait.