Modern JavaScript has built-in module support. Each file is a module with its own scope, and you choose what to share with named or default exports. Modules are loaded once and cached, which is the right behaviour for shared state. This lesson covers the export and import syntax, dynamic import, and how to structure a real project.
Named exports
Use export before any declaration to make it part of the module's public API. The importer pulls specific names in with curly braces. Named exports are explicit, easy to refactor, and the recommended default.
// math.js
export const PI = 3.14159265358979;
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// app.js
import { PI, add, multiply } from "./math.js";
console.log(PI); // 3.14159...
console.log(add(2, 3)); // 5
console.log(multiply(2, 3)); // 6
Default export for the main thing
A module can have a single default export. The importer picks any name they like. Use default export when the module has one main thing to offer, like a class or a function.
// greeter.js
export default function greet(name) {
return `Hello, ${name}!`;
}
// app.js
import greet from "./greeter.js";
console.log(greet("Asha")); // Hello, Asha!
Renaming imports and re-exports
Rename on import to avoid name collisions or improve clarity. Re-export from an index module to give consumers a single import path for your package.
// app.js
import { add as sum, multiply as times } from "./math.js";
console.log(sum(2, 3)); // 5
console.log(times(2, 3)); // 6
// index.js -- barrel file
export { add, multiply } from "./math.js";
export { default as greet } from "./greeter.js";
// app.js
import { add, greet } from "./index.js";
Dynamic import for code splitting
import() returns a promise that resolves to the module namespace. Use it to load code on demand, such as a heavy chart library only when the user opens a chart view. Bundlers like Vite and Webpack turn each dynamic import into a separate chunk.
async function showChart(data) {
const { renderChart } = await import("./chart.js");
renderChart(data);
}
Project structure
Small projects use a flat src folder. Larger projects split by feature or layer. A typical structure includes a package.json with type: "module" for native ESM, a tsconfig.json for TypeScript, and a bundler config.
myapp/
├── package.json
├── src/
│ ├── index.js
│ ├── components/
│ ├── lib/
│ └── styles/
└── public/
// package.json snippet
{
"name": "myapp",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest"
}
}
Summary
Use named exports for utilities, default exports for the main thing. Re-export from an index module to give consumers a clean import surface. Use dynamic import for code splitting and lazy loading. Set type: "module" in package.json to enable native ESM. The next lesson covers error handling patterns that make async code safe and debuggable.
Circular dependencies and how to avoid them
Two modules that import each other form a circular dependency. ES modules handle these at runtime: one of the modules gets a partially-initialized namespace when it imports the other. This usually works for simple cases but breaks when the imports are evaluated eagerly. The fix is to defer the import: pull the circular reference inside a function body, or split one of the modules into a smaller piece that does not depend on the other. Most linters flag circular imports so you see them at build time.
// a.js
import { b } from "./b.js";
export function a() { return b() + 1; }
// b.js
import { a } from "./a.js"; // circular
export function b() { return a() - 1; } // bug: a is partially initialized here
// Fix: defer the import
// b.js
let aRef;
export function setA(fn) { aRef = fn; }
export function b() { return aRef() - 1; }
Bundlers, transpilers, and TypeScript
Modern JavaScript projects rarely ship raw ESM to production. A bundler like Vite, esbuild, or Webpack resolves the module graph and produces optimized output. TypeScript adds type annotations and compiles to JavaScript before bundling. Node.js runs native ESM since v14; set type: "module" in package.json and use .js or .mjs extensions. Use .cjs for CommonJS modules in an otherwise ESM project. Keep the build pipeline simple: one bundler, one transpiler, one test runner. The point of modern tooling is to make iteration fast and the production output small.
Comparison with Python
Python's import system is older and more file-system oriented. A file mymodule.py is imported as import mymodule, and the import path is searched through environment variables and package metadata. JavaScript's ESM uses relative or absolute paths in import statements. Python's __init__.py marks a directory as a package; JavaScript has no equivalent because every file is a module by default. Python's from module import name plays the same role as JavaScript's import { name } from "module". Python's __all__ lists the public names of a module, similar in spirit to explicit named exports. Python's package management (pip, pyproject.toml) is older and more centralized than JavaScript's (npm, package.json), but both solve the same problem of finding and versioning third-party code.