Modules: Import & Export
Why Modules Exist
In the early days of JavaScript, every script shared a single global scope. If you loaded three separate JavaScript files in your HTML, every variable and function declared in one file was visible to all the others. This created constant naming collisions, unpredictable bugs, and code that was nearly impossible to maintain at scale. Imagine a team of ten developers all writing functions in the same global namespace -- eventually two people would name a function init() or handleClick(), and one would silently overwrite the other. Modules solve this problem by giving each file its own private scope and providing explicit mechanisms to share only what is needed.
The journey to modern JavaScript modules was long and evolved through several stages. Understanding this history helps you appreciate why ES modules work the way they do and why you still encounter older patterns in existing codebases.
The Script Tag Era: Global Scope Problems
Before any module system existed, developers loaded JavaScript with multiple <script> tags. Each script executed in the same global scope, which meant the order of script tags mattered critically. If app.js depended on a function from utils.js, you had to load utils.js first. Dependencies were implicit and managed entirely by the developer.
Example: The Global Scope Problem
<!-- utils.js declares a global function -->
<script src="utils.js"></script>
<!-- analytics.js also declares formatDate globally -->
<script src="analytics.js"></script>
<!-- app.js calls formatDate -- but which one? -->
<script src="app.js"></script>
<!-- The last script to define formatDate wins -->
<!-- This is a silent, hard-to-debug problem -->
As applications grew in complexity, this approach became unmanageable. Developers needed a way to encapsulate code, declare explicit dependencies, and avoid polluting the global namespace.
The IIFE Pattern: Early Encapsulation
Before formal module systems arrived, developers used Immediately Invoked Function Expressions (IIFEs) to create private scopes. An IIFE wraps code in a function that executes immediately, keeping variables private inside the function scope. Only values explicitly attached to the global object or returned from the IIFE are accessible outside.
Example: IIFE Module Pattern
// mathUtils is the only global variable created
var mathUtils = (function() {
// Private -- not accessible outside this IIFE
var PI = 3.14159265359;
var E = 2.71828182846;
function circleArea(radius) {
return PI * radius * radius;
}
function circleCircumference(radius) {
return 2 * PI * radius;
}
// Public API -- only these are accessible
return {
circleArea: circleArea,
circleCircumference: circleCircumference
};
})();
console.log(mathUtils.circleArea(5)); // 78.5398...
console.log(mathUtils.circleCircumference(5)); // 31.4159...
console.log(typeof PI); // "undefined" -- private
IIFEs were an improvement but still required manual management of load order and dependencies. They also relied on attaching modules to the global scope through naming conventions, which was fragile.
CommonJS: Modules for Node.js
When Node.js was created in 2009, it adopted the CommonJS module system. CommonJS uses require() to import modules and module.exports to export values. Each file is treated as a separate module with its own scope. CommonJS modules load synchronously, which works well on a server where files are read from the local disk but is problematic in browsers where files must be fetched over the network.
Example: CommonJS Modules (Node.js)
// mathUtils.js -- CommonJS export
const PI = 3.14159265359;
function circleArea(radius) {
return PI * radius * radius;
}
function circleCircumference(radius) {
return 2 * PI * radius;
}
module.exports = {
circleArea,
circleCircumference
};
// app.js -- CommonJS import
const { circleArea, circleCircumference } = require('./mathUtils');
console.log(circleArea(10)); // 314.159...
console.log(circleCircumference(10)); // 62.831...
require() and module.exports in countless npm packages. However, Node.js now supports ES modules natively, and the ecosystem is gradually migrating toward them.ES Modules: The Standard
ES modules (ESM) were introduced in ES2015 (ES6) as the official JavaScript module standard. Unlike CommonJS, ES modules use import and export statements that are statically analyzable -- the engine can determine all imports and exports at parse time without executing the code. This enables powerful optimizations like tree shaking, where bundlers remove unused exports from the final bundle. ES modules also load asynchronously, making them suitable for both browsers and servers.
The static nature of ES modules means you cannot use import inside an if statement or construct the module path dynamically with string concatenation (though dynamic import() handles those cases, as we will see later). This restriction is intentional -- it allows tools to analyze your dependency graph at build time.
Named Exports and Imports
Named exports allow you to export multiple values from a module. Each exported value has a specific name, and importing code must use that exact name (or rename it explicitly). This creates clear, self-documenting dependencies.
Example: Named Exports
// validators.js
// Export individual declarations directly
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function validateEmail(email) {
return EMAIL_REGEX.test(email);
}
export function validatePassword(password) {
if (password.length < 8) {
return { valid: false, message: 'Password must be at least 8 characters' };
}
if (!/[A-Z]/.test(password)) {
return { valid: false, message: 'Password must contain an uppercase letter' };
}
if (!/[0-9]/.test(password)) {
return { valid: false, message: 'Password must contain a number' };
}
return { valid: true, message: 'Password is valid' };
}
export function validateUsername(username) {
if (username.length < 3 || username.length > 20) {
return { valid: false, message: 'Username must be 3-20 characters' };
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
return { valid: false, message: 'Username can only contain letters, numbers, and underscores' };
}
return { valid: true, message: 'Username is valid' };
}
Example: Importing Named Exports
// form.js
// Import only the functions you need
import { validateEmail, validatePassword, validateUsername } from './validators.js';
function handleFormSubmit(formData) {
const emailResult = validateEmail(formData.email);
const passwordResult = validatePassword(formData.password);
const usernameResult = validateUsername(formData.username);
if (!emailResult) {
console.error('Invalid email address');
return false;
}
if (!passwordResult.valid) {
console.error(passwordResult.message);
return false;
}
if (!usernameResult.valid) {
console.error(usernameResult.message);
return false;
}
console.log('All validations passed!');
return true;
}
You can also group all exports at the bottom of the file using an export list, which some teams prefer because it makes the public API of a module immediately visible:
Example: Export List at Bottom
// helpers.js
const TAX_RATE = 0.08;
function calculateTax(amount) {
return amount * TAX_RATE;
}
function calculateTotal(amount) {
return amount + calculateTax(amount);
}
function formatCurrency(amount) {
return '$' + amount.toFixed(2);
}
// Single export statement at the bottom
export { TAX_RATE, calculateTax, calculateTotal, formatCurrency };
Default Exports and Imports
Each module can have at most one default export. Default exports are useful when a module has a single primary value -- a class, a configuration object, a main function, or a component. The importing code can choose any name for the default import without using curly braces.
Example: Default Export
// Logger.js
class Logger {
constructor(prefix) {
this.prefix = prefix;
this.logs = [];
}
info(message) {
const entry = `[INFO] ${this.prefix}: ${message}`;
this.logs.push(entry);
console.log(entry);
}
warn(message) {
const entry = `[WARN] ${this.prefix}: ${message}`;
this.logs.push(entry);
console.warn(entry);
}
error(message) {
const entry = `[ERROR] ${this.prefix}: ${message}`;
this.logs.push(entry);
console.error(entry);
}
getHistory() {
return [...this.logs];
}
}
export default Logger;
Example: Importing a Default Export
// app.js
// You can name the default import anything you want
import Logger from './Logger.js';
const appLogger = new Logger('App');
appLogger.info('Application started');
appLogger.warn('Configuration file not found, using defaults');
// You could also name it differently
import AppLogger from './Logger.js'; // Same thing, different name
Mixing Default and Named Exports
A module can have both a default export and named exports. This is common in libraries where the main functionality is the default export, while utility functions or constants are named exports.
Example: Mixed Exports
// httpClient.js
// Named exports for configuration and utilities
export const DEFAULT_TIMEOUT = 5000;
export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
export function buildQueryString(params) {
return Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
}
// Default export for the main class
class HttpClient {
constructor(baseURL, options = {}) {
this.baseURL = baseURL;
this.timeout = options.timeout || DEFAULT_TIMEOUT;
this.headers = options.headers || {};
}
async get(path, params = {}) {
const query = buildQueryString(params);
const url = query ? `${this.baseURL}${path}?${query}` : `${this.baseURL}${path}`;
const response = await fetch(url, {
method: 'GET',
headers: this.headers
});
return response.json();
}
async post(path, body) {
const response = await fetch(`${this.baseURL}${path}`, {
method: 'POST',
headers: { ...this.headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return response.json();
}
}
export default HttpClient;
Example: Importing Mixed Exports
// api.js
// Import default and named exports in one statement
import HttpClient, { DEFAULT_TIMEOUT, buildQueryString } from './httpClient.js';
const client = new HttpClient('https://api.example.com', {
timeout: DEFAULT_TIMEOUT * 2,
headers: { 'Authorization': 'Bearer token123' }
});
// Use the named export directly
const query = buildQueryString({ page: 1, limit: 20 });
console.log(query); // "page=1&limit=20"
Renaming Imports and Exports with the as Keyword
When importing from multiple modules that export values with the same name, or when you want to provide a more contextual name, use the as keyword to rename imports. You can also rename exports when exporting.
Example: Renaming Imports
// Both modules export a function called "validate"
import { validate as validateEmail } from './emailValidator.js';
import { validate as validatePhone } from './phoneValidator.js';
// Now you can use both without conflict
validateEmail('user@example.com');
validatePhone('+1-555-0123');
Example: Renaming Exports
// internalUtils.js
function _parseDate(str) {
// Internal implementation
return new Date(str);
}
function _formatDate(date) {
return date.toISOString().split('T')[0];
}
// Export with public-facing names
export {
_parseDate as parseDate,
_formatDate as formatDate
};
Namespace Import: Importing Everything
Sometimes you want to import all named exports from a module as a single object. This is called a namespace import and uses the * as syntax. This avoids long import lists and provides clear namespacing in your code.
Example: Namespace Import
// Import everything from the validators module
import * as validators from './validators.js';
// Access exports as properties of the namespace object
console.log(validators.validateEmail('test@example.com'));
console.log(validators.validatePassword('Secure123'));
console.log(validators.EMAIL_REGEX);
Re-Exporting: Creating Barrel Files
As projects grow, you often organize related modules into folders. A barrel file (typically named index.js) re-exports values from multiple modules in a directory, providing a single entry point for importing. This simplifies import paths and creates a clean public API for each folder.
Example: Project Structure with Barrel Files
src/
utils/
stringUtils.js
dateUtils.js
arrayUtils.js
index.js <-- barrel file
validators/
emailValidator.js
passwordValidator.js
index.js <-- barrel file
services/
apiService.js
authService.js
index.js <-- barrel file
Example: Barrel File (Re-Exporting)
// utils/stringUtils.js
export function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function truncate(str, maxLength) {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + '...';
}
// utils/dateUtils.js
export function formatDate(date) {
return date.toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric'
});
}
export function daysFromNow(days) {
const date = new Date();
date.setDate(date.getDate() + days);
return date;
}
// utils/arrayUtils.js
export function unique(arr) {
return [...new Set(arr)];
}
export function chunk(arr, size) {
const chunks = [];
for (let i = 0; i < arr.length; i += size) {
chunks.push(arr.slice(i, i + size));
}
return chunks;
}
// utils/index.js -- the barrel file
export { capitalize, truncate } from './stringUtils.js';
export { formatDate, daysFromNow } from './dateUtils.js';
export { unique, chunk } from './arrayUtils.js';
Example: Clean Imports with Barrel Files
// Without barrel file -- verbose, multiple import lines
import { capitalize, truncate } from './utils/stringUtils.js';
import { formatDate } from './utils/dateUtils.js';
import { unique } from './utils/arrayUtils.js';
// With barrel file -- single clean import
import { capitalize, truncate, formatDate, unique } from './utils/index.js';
You can also re-export default exports and rename re-exports:
Example: Advanced Re-Exporting
// services/index.js
// Re-export default as a named export
export { default as ApiService } from './apiService.js';
export { default as AuthService } from './authService.js';
// Re-export all named exports from a module
export * from './constants.js';
// Re-export with renaming
export { fetchUser as getUser } from './apiService.js';
export * from re-exports. If two modules export a value with the same name, you will get an error or one will silently shadow the other depending on the environment. Prefer explicit re-exports to keep your barrel file predictable.Dynamic import() for Code Splitting
Static import statements load modules before your code executes, which means all imported code is included in the initial bundle. For large applications, this can mean loading megabytes of JavaScript that the user may never need. Dynamic import() solves this by loading modules on demand at runtime. It returns a Promise that resolves to the module object.
Example: Dynamic Import for Code Splitting
// Load a heavy charting library only when the user needs it
async function showDashboard() {
const statusElement = document.getElementById('status');
statusElement.textContent = 'Loading dashboard...';
try {
// This module is loaded only when this function is called
const { default: ChartLibrary } = await import('./charts/ChartLibrary.js');
const chart = new ChartLibrary('#dashboard-container');
chart.render(dashboardData);
statusElement.textContent = 'Dashboard loaded!';
} catch (error) {
statusElement.textContent = 'Failed to load dashboard';
console.error('Dynamic import failed:', error);
}
}
// The chart library is NOT loaded until the button is clicked
document.getElementById('show-dashboard-btn')
.addEventListener('click', showDashboard);
Example: Conditional Imports Based on User Action
// Load different modules based on user preference
async function loadTheme(themeName) {
try {
const themeModule = await import(`./themes/${themeName}.js`);
themeModule.apply();
console.log(`Theme "${themeName}" applied successfully`);
} catch (error) {
console.error(`Theme "${themeName}" not found, using default`);
const defaultTheme = await import('./themes/default.js');
defaultTheme.apply();
}
}
// Load locale-specific formatting on demand
async function loadLocaleFormatter(locale) {
const formatter = await import(`./locales/${locale}.js`);
return formatter;
}
import() is the basis for code splitting in modern bundlers like Webpack, Rollup, and Vite. When the bundler encounters a dynamic import, it automatically creates a separate chunk (file) that is loaded only when needed. This can dramatically improve initial page load time for large applications.Module Scope and Strict Mode
ES modules have their own scope -- variables declared in a module are not added to the global object. Each module has its own top-level this value, which is undefined (not window as in regular scripts). Additionally, ES modules always run in strict mode automatically, even without the "use strict" directive. This means certain sloppy JavaScript behaviors are forbidden in modules.
Example: Module Scope Differences
// regularScript.js (loaded with <script src="...">)
var globalVar = 'I am global';
console.log(window.globalVar); // "I am global"
console.log(this === window); // true
// esModule.js (loaded with <script type="module" src="...">)
var moduleVar = 'I am scoped to this module';
console.log(window.moduleVar); // undefined -- NOT on window
console.log(this); // undefined -- not window
// Strict mode is automatic in modules
// These would all throw errors in a module:
// undeclaredVar = 42; // ReferenceError
// delete Object.prototype; // TypeError
// function f(a, a) {} // SyntaxError -- duplicate params
Loading Modules in the Browser
To use ES modules directly in the browser, add type="module" to your script tag. Module scripts are automatically deferred, meaning they do not block HTML parsing and execute after the DOM is fully parsed (equivalent to the defer attribute on regular scripts). Module scripts also execute only once, even if the same module is imported multiple times across different files.
Example: Using Modules in HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ES Modules in the Browser</title>
</head>
<body>
<h1>Module Demo</h1>
<div id="output"></div>
<!-- Regular script -- executes immediately, blocks parsing -->
<script src="legacy.js"></script>
<!-- Module script -- deferred automatically, has own scope -->
<script type="module" src="app.js"></script>
<!-- Inline module script -->
<script type="module">
import { formatDate } from './utils/dateUtils.js';
document.getElementById('output').textContent = formatDate(new Date());
</script>
<!-- Fallback for browsers that do not support modules -->
<script nomodule src="fallback-bundle.js"></script>
</body>
</html>
nomodule attribute is a clever fallback mechanism. Browsers that understand type="module" will ignore scripts with nomodule. Older browsers that do not understand type="module" will skip the module script (treating it as an unknown type) and execute the nomodule script instead. This lets you serve modern modular code to modern browsers and a bundled fallback to older ones.Circular Dependencies
A circular dependency occurs when module A imports from module B, and module B also imports from module A. While ES modules can handle circular dependencies (unlike CommonJS, which can produce partially loaded modules), they often indicate a design problem and should be refactored when possible.
Example: Circular Dependency Problem
// user.js
import { createDefaultSettings } from './settings.js';
export class User {
constructor(name) {
this.name = name;
this.settings = createDefaultSettings(this);
}
}
// settings.js
import { User } from './user.js';
export function createDefaultSettings(user) {
return {
theme: 'light',
language: 'en',
notifications: true,
displayName: user.name
};
}
// This is circular: user.js -> settings.js -> user.js
// ES modules handle this, but it can cause subtle issues
// if the timing of when exports are available matters
Example: Resolving Circular Dependencies
// Solution: Extract shared logic to a third module
// or restructure so the dependency flows one way
// types.js -- shared, no circular deps
export function createDefaultSettings(displayName) {
return {
theme: 'light',
language: 'en',
notifications: true,
displayName
};
}
// user.js -- imports from types.js only
import { createDefaultSettings } from './types.js';
export class User {
constructor(name) {
this.name = name;
this.settings = createDefaultSettings(this.name);
}
}
// settings.js -- imports from types.js only, no circular dependency
import { createDefaultSettings } from './types.js';
export { createDefaultSettings };
Organizing a Real Project with Modules
In a well-structured project, each module has a single responsibility. Group related modules into directories, use barrel files for clean imports, and keep your dependency graph flowing in one direction (from high-level modules to low-level utilities). Here is a practical example of a to-do application organized with modules:
Example: To-Do App Module Structure
src/
models/
Todo.js // Todo class with validation
TodoList.js // Collection logic
index.js // Barrel: export { Todo } from './Todo.js'; ...
services/
storageService.js // Save/load from localStorage
apiService.js // Sync with server
index.js // Barrel file
ui/
renderer.js // DOM manipulation
eventHandlers.js // Click and input handlers
index.js // Barrel file
utils/
dateUtils.js // Date formatting helpers
idGenerator.js // Unique ID generation
index.js // Barrel file
app.js // Entry point -- imports from barrels
Example: Entry Point Using Barrel Imports
// app.js -- the entry point
import { Todo, TodoList } from './models/index.js';
import { storageService } from './services/index.js';
import { renderer, eventHandlers } from './ui/index.js';
// Initialize the application
function init() {
const savedTodos = storageService.load('todos');
const todoList = new TodoList(savedTodos);
renderer.renderTodoList(todoList.getAll());
eventHandlers.onAddTodo((text) => {
const todo = new Todo(text);
todoList.add(todo);
storageService.save('todos', todoList.getAll());
renderer.renderTodoList(todoList.getAll());
});
eventHandlers.onToggleTodo((id) => {
todoList.toggle(id);
storageService.save('todos', todoList.getAll());
renderer.renderTodoList(todoList.getAll());
});
eventHandlers.onDeleteTodo((id) => {
todoList.remove(id);
storageService.save('todos', todoList.getAll());
renderer.renderTodoList(todoList.getAll());
});
}
init();
Module Best Practices Summary
Following these guidelines will help you write clean, maintainable modular JavaScript:
- One module, one responsibility -- Each file should do one thing well. If a module grows beyond 200-300 lines, consider splitting it.
- Prefer named exports -- They are easier to refactor, auto-import, and discover. Use default exports primarily for the main class or component of a file.
- Use barrel files for clean imports -- Create an
index.jsin each directory that re-exports the public API. Keep internal modules private by not re-exporting them. - Avoid circular dependencies -- If two modules need each other, extract the shared logic into a third module.
- Use dynamic
import()for large features -- Admin panels, charting libraries, and other heavy features should be loaded on demand. - Keep import statements at the top -- Static imports must be at the top level of the module. Keeping them grouped at the top improves readability.
- Always include the file extension -- In browser environments and Node.js ESM mode, you must include
.jsin import paths. Bundlers may allow omitting it, but being explicit is safer and more portable.
Hands-On Exercise
Build a modular calculator application with the following structure. Create a math/ directory containing four modules: basic.js (exports add, subtract, multiply, divide), advanced.js (exports power, squareRoot, factorial), converters.js (exports celsiusToFahrenheit, fahrenheitToCelsius, kmToMiles, milesToKm), and index.js as a barrel file that re-exports everything. Create a history.js module that default-exports a CalculationHistory class with methods add(entry), getAll(), clear(), and getLast(n). Create a main calculator.js file that imports from the barrel file and the history module, performs at least five different calculations, logs each result with the history module, and prints the calculation history at the end. Bonus: use dynamic import() to load the converters.js module only when a conversion function is called. Test your modules in the browser using type="module" script tags and verify that variables from one module are not accessible in another module's scope.