JavaScript Modules: Import, Export & ES Module System
JavaScript modules are the standard way to organize, share, and reuse code across files. The ES module system (ESM), introduced in ES2015, gives you import and export keywords to split your codebase into small, focused files that explicitly declare their dependencies. If you have been writing all your JavaScript in one large file, modules are the upgrade that will transform your development workflow.
In this lesson, you will learn how to export JavaScript functions, JavaScript variables, and classes from modules, how to import them in other files, and how to use advanced patterns like dynamic imports, barrel files, and module design best practices.
Why Modules Matter
Before modules, JavaScript developers relied on global variables, script tag ordering, and third-party module systems (CommonJS, AMD) to organize code. This led to:
- Name collisions — two scripts defining the same global variable
- Dependency confusion — unclear which files depend on which
- Loading order bugs — scripts must be loaded in the exact right order
- No encapsulation — everything is globally accessible
ES JavaScript modules solve all of these problems. Each module has its own scope, explicitly declares what it exports, and explicitly imports what it needs. The MDN JavaScript modules guide provides a thorough overview of the ecosystem.
Named Exports
Named exports let you export multiple values from a module. Each export is identified by its name:
// math.js — exporting individual items
export const PI = 3.14159265;
export const E = 2.71828182;
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
export class Calculator {
constructor() {
this.history = [];
}
calculate(expression) {
const result = eval(expression); // simplified example
this.history.push({ expression, result });
return result;
}
}
You can also export everything at the end of the file using an export list:
// utils.js — export list at the bottom
const API_BASE = "https://api.example.com";
const DEFAULT_TIMEOUT = 5000;
function formatDate(date) {
return new Intl.DateTimeFormat("en-US").format(date);
}
function formatCurrency(amount, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}
// Export everything at once
export { API_BASE, DEFAULT_TIMEOUT, formatDate, formatCurrency };
Default Exports
Each module can have one default export. Default exports are convenient when a module’s primary purpose is to export a single thing:
// UserService.js — default export
export default class UserService {
constructor(apiClient) {
this.api = apiClient;
}
async getUser(id) {
return this.api.get(`/users/${id}`);
}
async createUser(data) {
return this.api.post("/users", data);
}
async deleteUser(id) {
return this.api.delete(`/users/${id}`);
}
}
// logger.js — default export of a function
export default function log(level, message) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`);
}
// config.js — default export of an object
export default {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
debug: process.env.NODE_ENV !== "production",
};
You can combine default and named exports in the same module:
// api.js
export default class Api {
// main class
}
export const VERSION = "2.0.0";
export function createApi(config) {
return new Api(config);
}
Importing Modules
Import named exports using curly braces and default exports without:
// Importing named exports
import { add, multiply, PI } from "./math.js";
console.log(add(2, 3)); // 5
console.log(multiply(4, PI)); // 12.566...
// Importing default export
import UserService from "./UserService.js";
const userService = new UserService(apiClient);
// Importing both default and named exports
import Api, { VERSION, createApi } from "./api.js";
console.log(VERSION); // "2.0.0"
// Importing everything as a namespace object
import * as math from "./math.js";
console.log(math.add(1, 2)); // 3
console.log(math.PI); // 3.14159265
console.log(math.Calculator); // class Calculator
The import * as namespace pattern creates an objects containing all named exports. This is useful when a module exports many related items.
Renaming with as
Use as to rename imports and exports — essential when names conflict:
// Renaming imports to avoid conflicts
import { add as mathAdd } from "./math.js";
import { add as arrayAdd } from "./arrayUtils.js";
mathAdd(1, 2); // uses math.js add
arrayAdd(arr, 5); // uses arrayUtils.js add
// Renaming exports
function internalHelper() {
// implementation
}
export { internalHelper as publicHelper };
// Importing with renamed default
import { default as MyLogger } from "./logger.js";
// Equivalent to: import MyLogger from "./logger.js"
Re-Exporting (Barrel Files)
Barrel files re-export items from multiple modules, creating a single entry point. This is a powerful organizational pattern:
// services/index.js — barrel file
export { default as UserService } from "./UserService.js";
export { default as PostService } from "./PostService.js";
export { default as AuthService } from "./AuthService.js";
export { ApiError, NetworkError } from "./errors.js";
// Now consumers import from one place
import {
UserService,
PostService,
AuthService,
ApiError,
} from "./services/index.js";
Re-export syntax variations:
// Re-export everything from a module
export * from "./math.js";
// Re-export specific items
export { add, multiply } from "./math.js";
// Re-export with renaming
export { add as sum } from "./math.js";
// Re-export default as named
export { default as MathUtils } from "./math.js";
Barrel files are extremely common in larger applications and libraries. The javascript.info import/export guide covers this pattern in depth.
Dynamic Imports
Dynamic import() loads modules at runtime, returning a Promise. This enables code splitting and lazy loading:
// Load a module conditionally
async function loadChart(type) {
let ChartModule;
if (type === "bar") {
ChartModule = await import("./charts/BarChart.js");
} else if (type === "line") {
ChartModule = await import("./charts/LineChart.js");
} else {
ChartModule = await import("./charts/PieChart.js");
}
return new ChartModule.default(data);
}
// Lazy load a heavy module only when needed
document.querySelector("#admin-btn").addEventListener("click", async () => {
const { AdminPanel } = await import("./AdminPanel.js");
const panel = new AdminPanel();
panel.render();
});
// Dynamic import with destructuring
async function processCSV(file) {
const { parse, stringify } = await import("./csvUtils.js");
const data = parse(file);
return stringify(data);
}
Dynamic imports are essential for performance in large applications. They allow you to load code only when the user actually needs it, reducing initial page load time. This pattern works well with events — load modules in response to user interactions. The V8 dynamic import documentation explains how this works under the hood.
Module Features and Behavior
Strict Mode by Default
Modules always run in strict mode. You do not need "use strict":
// In a module, this would throw an error:
undeclaredVariable = 42; // ReferenceError: undeclaredVariable is not defined
// 'this' at the top level is undefined (not window)
console.log(this); // undefined
Module Scope
Each module has its own scope. Top-level variables are NOT global:
// moduleA.js
const secret = "hidden"; // not accessible from other modules
export const public_value = "visible";
// moduleB.js
console.log(secret); // ReferenceError: secret is not defined
import { public_value } from "./moduleA.js";
console.log(public_value); // "visible"
Modules Execute Once
A module’s code executes only once, even if imported by multiple files. Subsequent imports receive the same module instance:
// counter.js
console.log("Counter module loaded"); // only prints ONCE
let count = 0;
export function increment() {
return ++count;
}
export function getCount() {
return count;
}
// fileA.js
import { increment, getCount } from "./counter.js";
increment(); // count = 1
// fileB.js
import { getCount } from "./counter.js";
console.log(getCount()); // 1 — same instance!
This singleton behavior makes modules ideal for shared state, configuration, and service instances. It is closely related to how closures encapsulate state.
Live Bindings
Named exports are live bindings, not copies. If the exporting module changes a value, the importing module sees the change:
// state.js
export let currentUser = null;
export function login(user) {
currentUser = user;
}
// app.js
import { currentUser, login } from "./state.js";
console.log(currentUser); // null
login({ name: "Alice" });
console.log(currentUser); // { name: "Alice" } — live binding!
Using Modules in the Browser
To use ES modules in the browser, add type="module" to your script tag:
// In HTML:
// <script type="module" src="app.js"></script>
// Or inline:
// <script type="module">
// import { greet } from "./utils.js";
// greet("World");
// </script>
Key differences from regular scripts:
- Modules are deferred by default — they don’t block HTML parsing
- Modules run in strict mode
- Modules have their own scope (no global pollution)
- Modules execute only once, even if included multiple times
- Modules support
import/exportsyntax
// Import maps let you use bare specifiers in the browser
// <script type="importmap">
// {
// "imports": {
// "lodash": "https://cdn.jsdelivr.net/npm/lodash-es/lodash.js",
// "utils": "./src/utils.js"
// }
// }
// </script>
// Now you can write:
import _ from "lodash";
import { helper } from "utils";
The MDN guide on applying modules in HTML covers browser support and fallback patterns.
Using Modules in Node.js
Node.js supports ES modules in two ways:
// Option 1: Use .mjs file extension
// utils.mjs
export function greet(name) {
return `Hello, ${name}!`;
}
// app.mjs
import { greet } from "./utils.mjs";
// Option 2: Add "type": "module" to package.json
// package.json: { "type": "module" }
// Then all .js files are treated as ES modules
// Interop with CommonJS
import { readFile } from "fs/promises"; // Built-in ESM
import express from "express"; // CommonJS package works via default import
CommonJS vs ES Modules
| Feature | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| Syntax | require() / module.exports |
import / export |
| Loading | Synchronous | Asynchronous |
| Evaluation | Runtime (dynamic) | Parse time (static) |
| Tree shaking | Not possible | Supported |
| Top-level await | Not supported | Supported |
| Browser support | Needs bundler | Native |
Module Design Patterns
Feature Modules
// features/auth/
// ├── index.js (barrel file)
// ├── AuthService.js
// ├── authStore.js
// ├── LoginForm.js
// └── authUtils.js
// features/auth/index.js
export { default as AuthService } from "./AuthService.js";
export { login, logout, isAuthenticated } from "./authStore.js";
export { default as LoginForm } from "./LoginForm.js";
// Usage from anywhere in the app
import { AuthService, login, LoginForm } from "./features/auth/index.js";
Service Module Pattern
// services/api.js
const BASE_URL = "https://api.example.com";
let authToken = null;
// Private — not exported
function buildHeaders() {
const headers = { "Content-Type": "application/json" };
if (authToken) headers.Authorization = `Bearer ${authToken}`;
return headers;
}
// Public API
export function setToken(token) {
authToken = token;
}
export async function get(endpoint) {
const response = await fetch(`${BASE_URL}${endpoint}`, {
headers: buildHeaders(),
});
if (!response.ok) throw new Error(`GET ${endpoint}: ${response.status}`);
return response.json();
}
export async function post(endpoint, data) {
const response = await fetch(`${BASE_URL}${endpoint}`, {
method: "POST",
headers: buildHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`POST ${endpoint}: ${response.status}`);
return response.json();
}
This pattern uses module scope as encapsulation — buildHeaders and authToken are private by default because they are not exported. This is similar to how closures create private variables in JavaScript. See the javascript.info modules introduction for more on module scope.
Configuration Module
// config.js
const env = typeof process !== "undefined"
? process.env.NODE_ENV
: "production";
const configs = {
development: {
apiUrl: "http://localhost:3000/api",
debug: true,
logLevel: "verbose",
},
production: {
apiUrl: "https://api.myapp.com",
debug: false,
logLevel: "error",
},
};
export default configs[env] || configs.production;
// Usage
import config from "./config.js";
console.log(config.apiUrl); // depends on environment
Common Mistakes and Pitfalls
1. Importing Without Curly Braces for Named Exports
// math.js
export function add(a, b) { return a + b; }
// BUG: missing curly braces imports the default (which doesn't exist)
import add from "./math.js"; // undefined!
// FIX: use curly braces for named exports
import { add } from "./math.js"; // correct
2. Circular Dependencies
// a.js
import { b } from "./b.js";
export const a = "A" + b;
// b.js
import { a } from "./a.js";
export const b = "B" + a;
// This creates a circular dependency!
// One module will see undefined for the other's export
// FIX: restructure to eliminate the cycle, or use a shared module
3. Forgetting File Extensions in the Browser
// BUG: browsers require file extensions
import { utils } from "./utils"; // 404 error in browser!
// FIX: always include the file extension
import { utils } from "./utils.js"; // works
4. Trying to Use import in Regular Scripts
// BUG: import/export only work in modules
// <script src="app.js"></script> — this is a regular script!
import { something } from "./lib.js"; // SyntaxError!
// FIX: add type="module"
// <script type="module" src="app.js"></script>
5. Mutating Imported Bindings
// module.js
export let count = 0;
// app.js
import { count } from "./module.js";
count = 5; // TypeError: Assignment to constant variable
// Imported bindings are read-only in the importing module!
// FIX: export a function to modify the value
// module.js
export let count = 0;
export function setCount(val) { count = val; }
Summary and Key Takeaways
- ES modules use
importandexportto share code between files - Named exports allow multiple exports per file; default exports allow one primary export
- Use
asto rename imports/exports and avoid naming conflicts - Barrel files (re-exports) create clean public APIs for feature directories
- Dynamic imports (
import()) enable code splitting and lazy loading - Modules have their own scope, run in strict mode, and execute only once (singleton)
- Named exports are live bindings — changes in the source module are reflected in importers
- In browsers, use
<script type="module">; in Node.js, use.mjsor"type": "module" - Always include file extensions in browser module paths
- Avoid circular dependencies — restructure shared code into a common module
JavaScript modules are the last piece of the puzzle for writing organized, maintainable, production-ready code. Combined with the Fetch API, async/await, and promises, you now have a complete toolkit for modern JavaScript development. For the full specification, see the ECMAScript modules specification.