Modules & Classes
import/export, dynamic imports, class syntax, #private.
Modern JavaScript is organized into modules: small files that explicitly say what they expose and what they import. And while you don't need classes for most code, the syntax is clean and worth knowing because frameworks, error types, and a lot of built-in APIs use them.
ESM: import and export
ES modules (ESM) are the standard. Each file has its own scope. Anything you want other files to see, you export. Anything you want to use, you import.
// Named exports
export const PI = 3.14159;
export function square(n) { return n * n; }
// Default export (one per file)
export default function area(r) {
return PI * square(r);
}// Import the default + some named exports
import area, { PI, square } from "./math.js";
// Rename on import
import { square as sq } from "./math.js";
// Pull in everything as a namespace
import * as math from "./math.js";
math.PI; math.square(3); math.default(5);defaultfor a single "star" export when the file's purpose is one thing. Many style guides skip defaults entirely.Dynamic import: load on demand
Static imports happen at module-load time. Sometimes you want to wait, for example a heavy chart library only used after a user opens a tab. import() returns a promise:
button.addEventListener("click", async () => {
const { renderChart } = await import("./chart.js");
renderChart(data);
});Bundlers turn dynamic imports into separate code chunks the browser can fetch on demand. This is the foundation of code splitting.
Tree shaking: only ship what you use
Because ESM imports are static and named, bundlers can statically figure out which exports you actually used and drop the rest. That's tree shaking. It only works when:
- You use ESM (
import/export) syntax. - You import specific named exports, not whole namespaces.
- The module is "side-effect free" (no top-level work that must run regardless).
// Good for tree shaking
import { square } from "./math.js";
// Bad: pulls in everything, defeats tree shaking
import * as math from "./math.js";
math.square(2);Classes: the modern syntax
class Point {
// Public fields (declared up top, ES2022)
x = 0;
y = 0;
constructor(x, y) {
this.x = x;
this.y = y;
}
distance(other) {
const dx = this.x - other.x;
const dy = this.y - other.y;
return Math.hypot(dx, dy);
}
static origin() {
return new Point(0, 0);
}
}
const a = new Point(1, 2);
const b = Point.origin();
a.distance(b);Private fields with #
ES2022 added real private fields. They start with # and are completely inaccessible from outside the class:
class Counter {
#count = 0;
increment() { this.#count++; }
get value() { return this.#count; }
}
const c = new Counter();
c.increment();
c.value; // 1
c.#count; // SyntaxError - truly privateInheritance with extends
class Animal {
constructor(name) { this.name = name; }
speak() { return this.name + " makes a noise."; }
}
class Dog extends Animal {
speak() {
return super.speak() + " Specifically: woof.";
}
}
new Dog("Rex").speak();
// "Rex makes a noise. Specifically: woof."Static blocks
ES2022 also added static initialization blocks for setup that needs to run once when the class is loaded:
class Cache {
static instance;
static {
Cache.instance = new Cache();
console.log("Cache initialized");
}
}When to use a class
Reach for a class when you have a thing with both state and behavior, and you'll create multiple instances. For pure utility functions or one-off objects, plain functions and object literals are simpler. React, modern frameworks, and most modern code is mostly functions, not classes.
Try it
Quiz
Which import statement is easiest for a bundler to tree-shake?
Recap
- ESM is the standard: each file has its own scope and explicit
import/export. - Prefer named exports. Use defaults only when there's one obvious thing.
- Dynamic
import()returns a promise. Use it for code splitting and on-demand loading. - Classes have public fields, real private
#fields, and static blocks (ES2022). - Most modern code is functions. Reach for classes when you have stateful instances or framework patterns require them.