// math.js
export function add(a, b) { return a + b; }
export const PI = 3.14159;
// app.js
import { add, PI } from './math.js';
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159
// In HTML
// script type="module" src="app.js"
// Modules are:
// - Strict mode by default
// - Evaluated once (cached / singleton)
// - Deferred by default (like defer attribute)
// - Have their own scope (no global pollution)
// - Support top-level await
ES modules are the standard module system for JavaScript, supported in all modern browsers and Node.js 14+. Unlike scripts, modules do not pollute the global scope — each module has its own top-level variables. Modules are also fetched with CORS in browsers, requiring proper server headers.
Why it matters: ES modules are the foundation of all modern JavaScript development. Every bundler, framework, and build tool is built around them. Understanding how imports/exports work — including their live binding semantics — is essential for any production JavaScript developer.
Real applications: Every modern web app (React, Vue, Angular) is organized as ES modules, Node.js packages increasingly use ESM, shared utility libraries export named functions that tree-shaking can prune, and browser-native ESM enables no-build development workflows.
Common mistakes: Forgetting that ES module imports are live bindings (not copies), trying to use import inside non-module <script> tags, not setting CORS headers when serving modules from a different origin, and using require() syntax in an ESM context (SyntaxError).
// Named exports
export const name = 'Alice';
export function greet() { return 'Hi'; }
import { name, greet } from './user.js';
import { name as userName } from './user.js'; // rename
// Default export
export default class User { }
import User from './user.js'; // any name works
import MyUser from './user.js'; // same default, different name
// Mix both
export default class User { }
export const role = 'admin';
import User, { role } from './user.js';
// Exporting a value as default after declaration
const config = { debug: false };
export default config;
Use named exports when a module has multiple related utilities — they enable tree shaking and provide clear, discoverable API names. Use default exports for the primary export of a module (like a class or component). Many style guides prefer named exports for better refactoring support and IDE auto-import.
Why it matters: Default vs named exports is a team-level architectural decision. The wrong choice leads to poor IDE support, broken tree shaking, and confusing import syntax. Most modern projects (React included) are moving toward named-only exports.
Real applications: React components traditionally used default exports; utility libraries like lodash use named exports; icon libraries export every icon as a named export for tree shaking; and design system component libraries export all components as named exports for better discoverability.
Common mistakes: Mixing default and named exports in the same module (confusing for consumers), re-importing default exports under different names in different files (breaks refactoring), using default exports in shared utility modules (defeats tree shaking), and forgetting that export default is a single export — the module can only have one.
// Dynamic import returns a Promise
const module = await import('./heavy-module.js');
module.doSomething();
// Conditional loading
if (needsChart) {
const { Chart } = await import('./chart.js');
new Chart(canvas);
}
// Route-based code splitting
const routes = {
'/dashboard': () => import('./pages/dashboard.js'),
'/settings': () => import('./pages/settings.js')
};
async function navigate(path) {
const module = await routes[path]();
module.render();
}
// Error handling
try {
const mod = await import('./optional.js');
} catch (e) {
console.warn('Module not available');
}
Unlike static import declarations, dynamic import() can be used anywhere — inside conditionals, loops, event handlers, and even regular scripts (not just modules). Bundlers like Webpack and Vite recognize import() calls and automatically create separate chunks for code splitting.
Why it matters: Code splitting via dynamic import is the primary way to reduce initial bundle size and improve page load performance. Every production SPA should lazy-load routes and heavy components. This is the mechanism behind React.lazy, Vue's async components, and Next.js page routes.
Real applications: Lazy loading route components, loading heavy libraries (chart.js, PDF renderers) only when needed, feature-flagged code that's conditionally loaded, loading locale files on demand for i18n, and A/B test variants that download only when the test is active.
Common mistakes: Not wrapping dynamic import in try/catch (network failures throw unhandled rejections), under-utilizing it (importing everything statically increases bundle size), over-splitting (too many small chunks creates network waterfall), and forgetting that dynamic import returns a module namespace object (access default as .default).
// CommonJS (Node.js traditional)
const fs = require('fs');
module.exports = { readFile: fs.readFile };
module.exports.helper = function() {};
// ES Modules (modern standard)
import fs from 'fs';
export { readFile } from 'fs';
// Key differences:
// CJS: synchronous loading, dynamic, copies values
// ESM: async-compatible, static structure, live bindings
// CJS: require can be conditional
if (condition) { const m = require('./mod'); }
// ESM: import must be top-level
// if (condition) { import ... } // SyntaxError
// Use import() for dynamic loading instead
// CJS: module.exports can be reassigned at any time
// ESM: exports are determined at parse time (not runtime)
In Node.js, use .mjs extension or set "type": "module" in package.json to use ESM. CJS and ESM can interoperate but with caveats — you can import CJS modules from ESM, but require() cannot load ESM modules synchronously. The ecosystem is gradually migrating to ESM.
Why it matters: Node.js still has a massive CJS ecosystem. Navigating the interop between CJS and ESM — especially understanding why you can't require() an ESM package — is critical for building and consuming Node.js libraries.
Real applications: Publishing dual-format packages that work in both CJS and ESM consumers, consuming ESM-only packages in a CJS project (requires dynamic import), migrating legacy Node.js packages to ESM, and configuring build tools to output the right format for your target environment.
Common mistakes: Using __dirname/__filename in ESM (they don't exist — use import.meta.dirname), not adding "type": "module" to package.json and being confused why ESM syntax causes errors, and publishing ESM-only packages that break CJS consumers without a fallback.
// Without bundler: many HTTP requests
// script type="module" src="a.js" loads b.js loads c.js
// 3+ separate network requests (each module is a request)
// With bundler: single optimized file
// a.js + b.js + c.js = bundle.js (one request)
// Bundler capabilities:
// - Tree shaking: remove unused exports
// - Code splitting: separate chunks loaded on demand
// - Minification: smaller file sizes
// - Transpilation: modern JS to compatible JS
// - Asset handling: import CSS, images, JSON
// - Hot Module Replacement (HMR): live updates in dev
// Vite uses native ESM in dev (instant startup)
// and Rollup for production builds (optimized output)
// Webpack uses a module map and runtime loader
// esbuild is written in Go (extremely fast)
Modern bundlers have moved toward zero-config setups. Vite leverages native browser ESM during development for instant startup, only bundling for production. Rollup produces the smallest bundles through excellent tree shaking. esbuild prioritizes speed with parallel Go-based compilation.
Why it matters: Bundlers are not just a build step — they determine your development experience (hot reload speed), production performance (bundle size), and what module features you can use (code splitting, tree shaking, dynamic imports). Choosing the right bundler matters.
Real applications: Vite for React/Vue/Svelte SPA development (instant HMR), Rollup for publishing JavaScript libraries, Webpack for complex enterprise apps with custom loaders, esbuild as a compilation stage inside other tools (Vite, tsup), and Parcel for zero-config bundling of small projects.
Common mistakes: Using Webpack for new projects when Vite would be 10x faster, not configuring tree shaking properly (leaving sideEffects: false unset in package.json), adding heavy bundler plugins that defeat fast refresh (HMR), and not analyzing bundle output with tools like rollup-plugin-visualizer or webpack-bundle-analyzer.
// a.js
import { b } from './b.js';
export const a = 'A';
console.log(b); // "B" (already initialized by the time a.js runs)
// b.js
import { a } from './a.js';
export const b = 'B';
console.log(a); // undefined! (a.js hasn't finished executing)
// Execution order: b.js evaluates first (imported by a.js)
// At that point, a.js hasn't assigned 'a' yet
// Fix: use functions to defer access
// b.js
import { getA } from './a.js';
export const b = 'B';
// Access later: getA() returns "A"
// a.js
export const a = 'A';
export function getA() { return a; }
// Best practice: restructure to avoid circular deps
// Extract shared code into a third module
Circular dependencies are a code smell that usually indicates poor module boundaries. The best fix is to extract the shared dependency into a separate module that both can import. When unavoidable, use function calls (not direct value access) to defer access until all modules have initialized.
Why it matters: Circular dependencies cause mysterious undefined values at runtime — a class or function is imported but evaluates to undefined because the module hasn't finished initializing yet. This is a common source of subtle bugs in large codebases.
Real applications: Event-driven architectures where two modules need each other's event emitters, ORM models with bidirectional relationships, and deeply nested feature modules where utilities accidentally create cycles. Resolving them by extracting shared logic is a key refactoring skill.
Common mistakes: Not recognizing the circular dependency as the cause of an undefined value bug, creating circular deps by importing from a barrel file that re-exports the current module, and separating modules by type (models/, controllers/) instead of feature (prevents circular deps between features).
// utils/math.js
export function add(a, b) { return a + b; }
// utils/string.js
export function capitalize(s) { return s[0].toUpperCase() + s.slice(1); }
// utils/index.js — barrel file
export { add } from './math.js';
export { capitalize } from './string.js';
export { default as Helper } from './helper.js';
// Now consumers use a single import path
import { add, capitalize, Helper } from './utils/index.js';
// Re-export everything from a module
export * from './math.js';
// Rename on re-export
export { add as sum } from './math.js';
// Re-export default as named
export { default as MathHelper } from './math-helper.js';
Barrel files simplify imports for consumers but can hurt tree shaking if the bundler cannot eliminate unused re-exports. Some projects use direct imports (import from the specific file) in performance-critical scenarios to ensure optimal tree shaking.
Why it matters: Re-exporting via barrel files (index.js) is a foundational pattern for building clean APIs. But understanding the tree shaking tradeoffs is essential for library authors and performance-sensitive applications.
Real applications: Design system component libraries with an index.ts that re-exports all components, feature modules that expose a public API via a single barrel file, SDK packages that namespace exports from multiple internal modules, and monorepo workspaces with shared utility packages.
Common mistakes: Creating deeply nested barrel file chains that make tree shaking impossible, importing directly from a barrel file in a large app and pulling in all its dependencies, forgetting that export * from without as re-exports everything (including internals you didn't intend to expose), and barrel-filing in a way that creates circular dependencies.
// Get current module URL
console.log(import.meta.url);
// "file:///project/src/app.js" or "https://example.com/app.js"
// Resolve relative paths
const dataUrl = new URL('./data.json', import.meta.url);
const response = await fetch(dataUrl);
// Vite-specific properties
// import.meta.env.MODE — "development" or "production"
// import.meta.env.VITE_API_URL — custom env variable
// import.meta.hot — HMR API
// Node.js (v21+)
// import.meta.dirname — equivalent to __dirname
// import.meta.filename — equivalent to __filename
// Node.js (older versions)
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import.meta is extensible by the host environment. Bundlers like Vite add import.meta.env for environment variables and import.meta.hot for Hot Module Replacement. Node.js recently added import.meta.dirname and import.meta.filename as replacements for the CJS __dirname and __filename.
Why it matters: import.meta is the ESM equivalent of CommonJS globals like __dirname and require. It's essential for any ESM code that needs to know its own URL, resolve relative paths, or access environment-specific metadata.
Real applications: Resolving file paths relative to the current module in Node.js ESM (import.meta.dirname), accessing build-time environment variables in Vite apps (import.meta.env.VITE_API_URL), implementing Hot Module Replacement in custom tools, and detecting whether the current module is the entry point.
Common mistakes: Trying to use __dirname in an ESM file (it doesn't exist — use new URL('.', import.meta.url).pathname or import.meta.dirname), using Vite's import.meta.env in a non-Vite environment (undefined), and serializing import.meta.url thinking it will work at runtime (it's statically replaced by bundlers).
// utils.js
export function used() { return 'I am used'; }
export function unused() { return 'I am not used'; }
// app.js
import { used } from './utils.js';
console.log(used());
// After tree shaking: unused() is removed from bundle
// Requirements for effective tree shaking:
// 1. ES module syntax (import/export, not require)
// 2. No side effects in module top-level code
// 3. Static imports (not dynamic require())
// 4. Pure function calls (no hidden state changes)
// Mark package as side-effect-free in package.json
// { "sideEffects": false }
// Or specify files WITH side effects
// { "sideEffects": ["./src/polyfills.js", "*.css"] }
// Side effects prevent tree shaking:
// import './global-styles.css'; // side effect — must keep
Tree shaking only works with ES modules because their import/export structure is determined at parse time (statically analyzable). CommonJS require() is dynamic and cannot be tree-shaken. Mark your library as side-effect-free in package.json to help bundlers aggressively remove unused code.
Why it matters: Tree shaking is the primary mechanism for keeping bundle sizes small. Without it, importing a single utility from a library would bundle the entire library. This directly impacts application load time and user experience.
Real applications: Importing only needed lodash-es functions instead of the whole library, building React component libraries that let consumers import individual components without the rest, and optimizing third-party SDK bundles by tree-shaking unused features.
Common mistakes: Using CommonJS format for published libraries (prevents tree shaking by consumers), not setting "sideEffects": false in package.json (bundler can't remove dead code), writing code that relies on module evaluation side effects (may be removed by tree shaking), and importing from CJS entry points of dual-format packages instead of the ESM entry.
// Dynamic import with error handling
try {
const mod = await import('./optional-feature.js');
mod.init();
} catch (e) {
console.warn('Feature not available:', e.message);
// Provide fallback behavior
}
// Promise-based
import('./analytics.js')
.then(m => m.track('pageview'))
.catch(() => console.warn('Analytics unavailable'));
// Fallback pattern — try primary, fall back to alternative
async function loadModule(primary, fallback) {
try {
return await import(primary);
} catch {
return await import(fallback);
}
}
const charts = await loadModule('./fancy-charts.js', './basic-charts.js');
Static import failures (like a 404 or syntax error in the imported module) cause the entire module graph to fail loading. For optional features or progressive enhancement, always use dynamic import() with error handling so your application can gracefully degrade.
Why it matters: Module loading errors can silently crash an entire application if not handled. The static module graph failing versus dynamic import failing have dramatically different consequences: static failures are unrecoverable; dynamic import failures are catchable.
Real applications: Feature detection with graceful degradation (try to load advanced feature module, fall back if unavailable), handling CDN outages for externalized dependencies, implementing retry logic for flaky network conditions, and providing offline fallbacks in PWAs.
Common mistakes: Not catching errors from dynamic import (unhandled promise rejection), not testing module loading failures (only testing the happy path), using static import for truly optional features (should be dynamic with error handling), and not providing user feedback when a required resource fails to load.
// math.js
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
// Import entire module as namespace
import * as math from './math.js';
console.log(math.PI); // 3.14159
console.log(math.add(2, 3)); // 5
console.log(math.multiply(4, 5)); // 20
// Namespace is a frozen object — cannot modify
// math.PI = 0; // TypeError in strict mode
// Default export is available as .default
import * as mod from './module-with-default.js';
console.log(mod.default); // the default export
// Useful for utilities with many exports
import * as validators from './validators.js';
if (validators.isEmail(input)) { }
if (validators.isPhone(input)) { }
Module namespaces are live — if the exporting module updates a binding, the namespace reflects the change. They are also not iterable with for...of but you can use Object.keys() or Object.entries() to inspect their contents. Namespace imports may prevent tree shaking in some bundlers.
Why it matters: Namespace imports (import * as utils from './utils') are a convenient but sometimes performance-costly pattern. Knowing their live binding behavior and tree-shaking implications helps you make informed choices between namespace and selective imports.
Real applications: Importing all exports from a utility module as a namespace to use as utils.formatDate(), dynamically accessing exports by computed string keys, building plugin systems where plugins are entire module namespaces, and testing where you want to spy on all exports of a module at once.
Common mistakes: Expecting to iterate namespace imports with for...of (not supported — use Object.keys), not knowing that namespace imports may pull the entire module into the bundle (poor tree shaking), and forgetting that namespace imports are live — a module variable that changes after import will reflect in the namespace.
// config.js — top-level await
const response = await fetch('/api/config');
export const config = await response.json();
// Importing modules wait for this to resolve
// db.js — async initialization
const connection = await connectToDatabase();
export { connection };
// app.js — these run after config.js resolves
import { config } from './config.js';
console.log(config.apiUrl); // guaranteed to be loaded
// Conditional async loading
const features = await import(
navigator.language.startsWith('ja')
? './i18n/ja.js'
: './i18n/en.js'
);
// Error handling still applies
// If the awaited promise rejects, the module fails to load
// and all importing modules fail too
Top-level await is supported in ES modules only (not CommonJS or regular scripts). It blocks the evaluation of the current module and all modules that depend on it. Use it sparingly — overuse can create loading waterfalls where modules load sequentially instead of in parallel.
Why it matters: Top-level await enables clean initialization patterns for modules that need async setup (loading config, connecting to DB) without wrapping everything in an IIFE. But its blocking semantics have important performance implications that need to be understood.
Real applications: Loading remote configuration before the module is ready to use, database connection initialization in Node.js server modules, polyfill loading that depends on feature detection, and module-level lazy loading sequences in server-side rendering.
Common mistakes: Using top-level await in a shared utility module (blocks every importer), creating loading waterfalls by awaiting in series instead of using Promise.all(), expecting top-level await to work in CommonJS files (SyntaxError), and not understanding that the importing module waits for the entire module to finish evaluation before proceeding.
// Feature-based structure (recommended)
// src/
// auth/
// index.js — public API (barrel file)
// auth.service.js
// auth.utils.js
// login.component.js
// users/
// index.js
// users.service.js
// user.model.js
// shared/
// index.js
// http.js
// validators.js
// auth/index.js — barrel file
export { AuthService } from './auth.service.js';
export { login, logout } from './auth.utils.js';
// Internals are NOT exported
// Consumer imports from the barrel
import { AuthService, login } from './auth/index.js';
// Dependency rules:
// - Features import from shared/ — OK
// - shared/ never imports from features — correct
// - Features should not import from each other's internals
// - Use dependency injection for cross-feature communication
Follow the Dependency Rule: dependencies should point inward toward shared/core modules, never outward toward feature modules. This creates a clear hierarchy and prevents circular dependencies. Use barrel files to define the public API of each feature folder.
Why it matters: Module organization directly determines a codebase's maintainability. Poor organization leads to entangled, hard-to-test code. Feature-based (vertical) organization scales far better than type-based (horizontal) organization in large applications.
Real applications: Organizing a React app into feature folders (auth/, products/, checkout/) each with their own components, hooks, utils, and tests; Node.js services where each domain has a self-contained module; and monorepo packages where each workspace is an independent feature module.
Common mistakes: Organizing by type (components/, styles/, utils/) instead of feature (creates distributed, hard-to-delete features), deeply nesting folders beyond 3-4 levels (hard to navigate), and not defining explicit public APIs via barrel files (leads to consumers importing internal implementation details).
// Module WITH side effects — runs code at import time
// polyfill.js
if (!Array.prototype.flat) {
Array.prototype.flat = function() { /* ... */ };
}
// Just importing this file modifies Array.prototype
import './polyfill.js'; // side effect — must not be tree-shaken
// Module WITHOUT side effects — pure exports
// utils.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
// Safe to remove unused exports
// CSS imports are side effects
import './styles.css'; // modifies page appearance
// Tell bundler in package.json:
// { "sideEffects": false } — no side effects anywhere
// { "sideEffects": ["*.css", "polyfill.js"] } — only these files
// Side effect in module scope (avoid when possible)
let counter = 0;
export function increment() { return ++counter; }
// This module has state — considered a side effect
When building a library, marking it as side-effect-free in package.json allows bundlers to aggressively tree-shake unused exports. If your module must have side effects (polyfills, CSS), list them explicitly so the bundler knows not to remove those imports.
Why it matters: Side effects are the reason import-for-side-effect patterns exist (import './polyfill.js'). Without declaring them, bundlers may incorrectly tree-shake polyfills, CSS imports, or any module that does work at evaluation time without exporting anything.
Real applications: CSS-in-JS libraries that inject styles on import, polyfill modules that patch globals, analytics SDK initialization files, and service worker registration modules. All require explicit side-effect declarations to survive tree shaking.
Common mistakes: Setting "sideEffects": false when your package has polyfill or CSS files (they get tree-shaken out of consumer bundles), not knowing that UI component libraries often list CSS files as sideEffects, and confusing module-level side effects with observable function side effects.
// Relative imports — resolve to files
import { helper } from './utils.js'; // ./utils.js
import config from '../config.js'; // ../config.js
// Bare specifiers — node_modules lookup
import lodash from 'lodash';
// Searches: ./node_modules/lodash, ../node_modules/lodash, etc.
// Resolution order for require('foo'):
// 1. Built-in module? (fs, path, http) — use it
// 2. Starts with ./ or ../ or /? — resolve as file/directory
// 3. Look in node_modules/ (walk up directory tree)
// Package.json "exports" field (modern)
// { "exports": { ".": "./src/index.js", "./utils": "./src/utils.js" } }
import pkg from 'my-lib'; // resolves to "./src/index.js"
import utils from 'my-lib/utils'; // resolves to "./src/utils.js"
// Conditional exports
// { "exports": { "import": "./esm/index.js", "require": "./cjs/index.js" } }
The exports field in package.json is the modern way to define entry points. It replaces the main field and supports conditional exports — different entry points for ESM vs CJS, browser vs Node.js, development vs production. It also encapsulates the package, preventing imports of internal files not listed in exports.
Why it matters: Node.js module resolution determines how import 'my-package' resolves to a file. Understanding the resolution algorithm is critical for debugging mysterious "module not found" errors and for publishing packages that work correctly across environments.
Real applications: Publishing dual-format packages (ESM + CJS) with the exports field, preventing consumers from deep-importing internal implementation files, providing different builds for browser vs Node.js, and shipping smaller production builds by pointing to pre-minified/pre-bundled files.
Common mistakes: Using only the main field (no conditional exports, no ESM support), not testing the package resolution from a consumer's perspective (what npm link exposes vs what exports allows), and publishing packages where exports doesn't include a types entry, breaking TypeScript consumers.
// In HTML — define import map before any module scripts
// script type="importmap"
// {
// "imports": {
// "lodash": "https://cdn.jsdelivr.net/npm/lodash-es/lodash.js",
// "react": "/vendor/react.js",
// "utils/": "./src/utils/"
// }
// }
// Now bare specifiers work in the browser!
import _ from 'lodash'; // resolves to CDN URL
import React from 'react'; // resolves to /vendor/react.js
import { add } from 'utils/math.js'; // resolves to ./src/utils/math.js
// Scoped remapping
// {
// "imports": { "lodash": "/vendor/lodash-v4.js" },
// "scopes": {
// "/legacy/": { "lodash": "/vendor/lodash-v3.js" }
// }
// }
// Modules in /legacy/ use lodash v3, others use v4
Import Maps are supported in all modern browsers and are available in Deno natively. They enable running ES modules in the browser without any build step — useful for prototyping, small projects, and progressive enhancement. For production, bundlers still provide better optimization through tree shaking and minification.
Why it matters: Import Maps are the browser-native solution to package resolution — they let you use bare import specifiers (import React from 'react') without a bundler. This is increasingly relevant as more teams adopt CDN-delivered modules for rapid prototyping.
Real applications: No-build-step prototypes, educational platforms that let users run ES module code in the browser, Deno applications that use HTTPS imports, and progressive enhancement strategies that add modules to server-rendered pages without a full build pipeline.
Common mistakes: Expecting Import Maps to provide tree shaking or bundling (they don't — each module is a separate request), not realizing that CDN-served libraries may not have all the modules your project needs, and using Import Maps in production without caching strategy (many HTTP requests for untouched modules).
// user.service.js
import { fetchUser } from './api.js';
export async function getUser(id) {
const user = await fetchUser(id);
return { ...user, displayName: user.firstName + ' ' + user.lastName };
}
// user.service.test.js (Vitest/Jest)
import { describe, it, expect, vi } from 'vitest';
import { getUser } from './user.service.js';
// Mock the dependency
vi.mock('./api.js', () => ({
fetchUser: vi.fn().mockResolvedValue({
firstName: 'Alice', lastName: 'Smith'
})
}));
describe('getUser', () => {
it('creates display name', async () => {
const user = await getUser(1);
expect(user.displayName).toBe('Alice Smith');
});
});
// Dependency injection pattern (no mocking needed)
export function createUserService(api) {
return {
async getUser(id) {
const user = await api.fetchUser(id);
return { ...user, displayName: user.firstName + ' ' + user.lastName };
}
};
}
The dependency injection pattern makes modules testable without framework-specific mocking tools. Pass dependencies as parameters instead of importing them directly. This also improves reusability and makes the dependency graph explicit.
Why it matters: Module testability is a core software quality concern. Modules that directly import their dependencies are hard to test in isolation. Dependency injection — passing dependencies as parameters or through factories — is the fundamental pattern for making modules independently testable.
Real applications: Passing a fetch function as a parameter instead of calling it directly (allows test mocking), using module factories in Jest to replace imported modules with mocks, structuring Node.js services so each module receives its database connection rather than importing it directly, and building Angular-style injectable services.
Common mistakes: Testing module integration instead of unit testing (slow, brittle), not resetting module mocks between tests (test state leakage), tightly coupling modules to specific implementations of their dependencies (can't mock), and using jest.mock() without understanding how module caching affects the mocked module.
// Classic script
// script src="app.js"
// - Shares global scope (var creates window properties)
// - Executes synchronously (blocks parsing)
// - No import/export support
// - this === window at top level
// - Can be loaded cross-origin without CORS
// Module script
// script type="module" src="app.js"
// - Own scope (variables don't leak to window)
// - Deferred by default (like adding defer attribute)
// - import/export syntax available
// - Strict mode by default
// - this === undefined at top level
// - Fetched with CORS (requires proper headers)
// - Executed only once even if included multiple times
// Inline modules
// script type="module"
// import { add } from './math.js';
// console.log(add(1, 2)); // works inline too
// nomodule fallback for older browsers
// script nomodule src="legacy-bundle.js"
// Only runs in browsers that DON'T support modules
The nomodule attribute provides a clean fallback pattern: modern browsers ignore scripts with nomodule (they use the module version), while older browsers ignore type="module" scripts and load the nomodule fallback. This enables differential serving — smaller modern bundles for capable browsers.
Why it matters: Script vs module behavior differences affect everything from variable scoping to CORS behavior to deferred loading. Accidentally mixing script and module contexts is a common source of mysterious "variable not defined" or CORS errors.
Real applications: Setting type="module" on all modern app entry points, using nomodule for legacy browser fallbacks, understanding why top-level var in modules doesn't become a window property, and debugging why this is undefined at the top level of a module.
Common mistakes: Forgetting that module scripts are always deferred (already async-safe), using var in modules and expecting window-global behavior, not setting type="module" and then being confused why ES module syntax causes SyntaxError, and not knowing that modules are on strict mode by default.