I remember the days before ES6 when I had to fake object-oriented patterns in JavaScript using prototype chains and immediately-invoked function expressions. It worked, but it wasn’t pretty, and it definitely wasn’t beginner-friendly. When class syntax and native modules arrived, they didn’t just make my code prettier — they fundamentally changed how I structure applications. In this article, I’ll walk you through classes and modules from the ground up, including what’s actually happening under the hood.
Why Classes and Modules Matter
As applications grow, two problems show up constantly: managing complex, related state and behavior (classes solve this), and managing how code from different files talks to each other without polluting the global scope (modules solve this). I use both of these every single day in production code, whether I’m building a React app, a Node.js API, or a simple browser script.
Classes in JavaScript
The Basics
A class is a blueprint for creating objects with shared structure and behavior. Under the hood, JavaScript classes are syntactic sugar over the existing prototype-based inheritance model — I’ll explain exactly what that means shortly.
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
return `Hi, I'm ${this.name} and I'm ${this.age} years old.`;
}
}
const alice = new Person("Alice", 30);
console.log(alice.greet());
// Output: Hi, I'm Alice and I'm 30 years old.
- The
constructormethod runs automatically when I create a new instance withnew. - Methods defined inside the class body (like
greet) are added to the class’sprototype, not to each individual instance — this is a memory-efficiency detail I’ll come back to.
Inheritance with extends and super
class Employee extends Person {
constructor(name, age, role) {
super(name, age); // calls the parent constructor
this.role = role;
}
greet() {
return `${super.greet()} I work as a ${this.role}.`;
}
}
const bob = new Employee("Bob", 28, "Developer");
console.log(bob.greet());
// Output: Hi, I'm Bob and I'm 28 years old. I work as a Developer.
I use super() to call the parent class’s constructor — this is mandatory before I can use this in a derived class’s constructor. I use super.methodName() to call an overridden parent method from a child class.
Getters, Setters, and Computed Properties
class Circle {
constructor(radius) {
this._radius = radius;
}
get area() {
return Math.PI * this._radius ** 2;
}
set radius(value) {
if (value <= 0) throw new Error("Radius must be positive");
this._radius = value;
}
}
const c = new Circle(5);
console.log(c.area); // 78.53981633974483
c.radius = 10;
console.log(c.area); // 314.1592653589793
Getters and setters let me expose computed or validated properties while keeping the interface clean — I access c.area like a property, not a method call.
Static Methods and Properties
Static members belong to the class itself, not to instances. I use these for utility functions or shared configuration that doesn’t need a specific instance.
class MathUtils {
static square(x) {
return x * x;
}
static PI = 3.14159;
}
console.log(MathUtils.square(4)); // 16
console.log(MathUtils.PI); // 3.14159
Private Fields and Methods
Modern JavaScript supports true private class members using the # prefix — these are genuinely inaccessible from outside the class, not just a naming convention like the old _underscore style.
class BankAccount {
#balance = 0;
constructor(owner) {
this.owner = owner;
}
deposit(amount) {
this.#balance += amount;
return this.#balance;
}
#logTransaction(type, amount) {
console.log(`${type}: ${amount}`);
}
withdraw(amount) {
if (amount > this.#balance) throw new Error("Insufficient funds");
this.#balance -= amount;
this.#logTransaction("Withdraw", amount);
return this.#balance;
}
}
const account = new BankAccount("Alice");
account.deposit(100);
console.log(account.withdraw(30)); // 70
console.log(account.#balance); // SyntaxError: Private field '#balance' must be declared in an enclosing class
I’ve fully switched to # private fields in new code because they enforce real encapsulation, which underscore-prefixed properties never actually did.
Abstract-Like Patterns
JavaScript doesn’t have true abstract classes, but I simulate them like this:
class Shape {
constructor() {
if (new.target === Shape) {
throw new Error("Cannot instantiate abstract class Shape directly");
}
}
area() {
throw new Error("area() must be implemented by subclass");
}
}
class Square extends Shape {
constructor(side) {
super();
this.side = side;
}
area() {
return this.side ** 2;
}
}
Internal Working: Classes and the Prototype Chain
This is the part I think every JavaScript developer should understand deeply. When I write:
class Animal {
speak() {
return "...";
}
}
JavaScript actually creates a constructor function under the hood, and speak gets added to Animal.prototype, not to each instance. When I do new Animal(), the new object’s internal [[Prototype]] points to Animal.prototype. When I call myAnimal.speak(), the engine looks for speak on the instance first, doesn’t find it, then walks up the prototype chain and finds it on Animal.prototype.
console.log(typeof Animal); // "function"
console.log(Object.getPrototypeOf(new Animal()) === Animal.prototype); // true
This matters for memory: if I put methods on the prototype (which class syntax does automatically), every instance shares the same function in memory instead of each instance getting its own copy. This is a huge performance and memory win when I’m creating thousands of objects.
Class declarations are also not hoisted the same way function declarations are — they exist in a “temporal dead zone” until the class definition is evaluated, so referencing a class before its declaration throws a ReferenceError.
Modules in JavaScript
Why Modules Exist
Before ES Modules, I had to rely on tools like CommonJS (require/module.exports in Node.js) or bundler-specific patterns, or just dump everything into global scope with <script> tags — which caused naming collisions constantly. ES Modules (ESM) solved this natively.
Named Exports and Imports
// mathUtils.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export const PI = 3.14159;
// main.js
import { add, subtract, PI } from "./mathUtils.js";
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159
Default Exports
// Logger.js
export default class Logger {
log(message) {
console.log(`[LOG]: ${message}`);
}
}
// main.js
import Logger from "./Logger.js";
const logger = new Logger();
logger.log("App started"); // [LOG]: App started
I generally use default exports for the single “main” thing a file provides (like a class or component), and named exports for utility functions or multiple related exports.
Renaming and Namespace Imports
import { add as sum } from "./mathUtils.js";
import * as MathUtils from "./mathUtils.js";
console.log(sum(1, 2)); // 3
console.log(MathUtils.subtract(5, 2)); // 3
Dynamic Imports
I use dynamic import() when I want to load a module lazily, for example, to split code and improve initial page load performance.
button.addEventListener("click", async () => {
const { default: Modal } = await import("./Modal.js");
const modal = new Modal();
modal.open();
});
Dynamic imports return a Promise, so they integrate naturally with async/await.
CommonJS vs. ES Modules
| Feature | CommonJS | ES Modules |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous | Asynchronous (supports static analysis) |
| Environment | Node.js (default historically) | Browsers and modern Node.js |
| Tree-shaking | Not natively supported | Supported by bundlers |
this at top level | module.exports object | undefined |
In Node.js, I enable ES Modules either by naming files .mjs or by setting "type": "module" in package.json.
{
"type": "module"
}
Internal Working: How Module Loading Works
ES Modules are statically analyzed — the engine parses import/export statements before running any code, building a dependency graph. This static structure is what allows bundlers like Webpack, Rollup, and Vite to perform tree-shaking, removing unused exports from the final bundle.
Modules also run in strict mode automatically, and each module has its own top-level scope — nothing leaks into the global scope unless I explicitly attach it to window or globalThis. Additionally, modules are only executed once, even if imported multiple times across different files — the engine caches the module’s exports after the first evaluation.
Practical, Real-World Patterns
Organizing a Small App with Classes and Modules
// models/User.js
export class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
}
// services/UserService.js
import { User } from "../models/User.js";
export class UserService {
#users = [];
addUser(name, email) {
const user = new User(name, email);
this.#users.push(user);
return user;
}
getUsers() {
return [...this.#users];
}
}
// main.js
import { UserService } from "./services/UserService.js";
const service = new UserService();
service.addUser("Alice", "alice@example.com");
console.log(service.getUsers());
This separation — models for data shape, services for business logic — is a pattern I reuse across almost every project.
Best Practices
- I keep classes focused on a single responsibility rather than letting them balloon into “god objects.”
- I favor composition over deep inheritance chains — more than two or three levels of
extendsusually signals a design problem. - I use
#privatefields for internal state that shouldn’t be touched from outside. - I always use named exports for utility modules with multiple functions, and default exports for single-purpose files.
- I avoid circular imports between modules — they cause subtle bugs where one module gets a partially-initialized version of another.
Common Mistakes to Avoid
- Forgetting
super()in a derived class constructor — this throws aReferenceErrorthe moment you try to usethis. - Mixing CommonJS and ESM syntax in the same file — pick one per project (or per file extension) and stick to it.
- Using
thisincorrectly in class methods passed as callbacks — regular methods lose theirthisbinding when detached, so I use arrow function class fields or.bind()when needed. - Overusing inheritance — I remind myself constantly that composition is usually more flexible.
class Button {
constructor(label) {
this.label = label;
// Arrow function field automatically binds 'this'
this.handleClick = () => {
console.log(`${this.label} clicked`);
};
}
}
Debugging Tips
- I use
console.log(instance.constructor.name)to quickly check what class an object was created from. - I use
instanceofto verify inheritance relationships during debugging. - For module loading issues, I check the Network tab in DevTools to confirm files are actually being fetched with the correct MIME type (
application/javascriptortext/javascript).
Security Considerations
Private class fields (#field) provide genuine encapsulation, which is useful for hiding sensitive internal state, but I never rely on client-side JavaScript alone to protect truly sensitive data like API keys or credentials — anything shipped to the browser can eventually be inspected. Server-side validation and secrets management remain essential regardless of how “private” my JavaScript class fields are.
FAQs
Q: Are JavaScript classes “real” classes like in Java or C++? A: Not exactly. They’re syntactic sugar over prototypal inheritance. Under the hood, it’s still prototypes doing the work, which is different from classical class-based languages.
Q: Can I use both import and require in the same Node.js project? A: You can, but it requires careful configuration (like using .cjs/.mjs extensions) and can get confusing. I recommend picking one module system per project.
Q: What happens if I forget to export something from a module? A: Trying to import a name that wasn’t exported gives you undefined in older engines or a SyntaxError/warning in strict ESM environments — always double check your export statements.
Q: Do ES Modules support top-level await? A: Yes, modern ES Modules support top-level await, letting me await a promise directly at the top level of a module without wrapping it in an async function.
Summary and Key Takeaways
- Classes are syntactic sugar over JavaScript’s prototype-based inheritance model.
#privatefields give real encapsulation; use them for internal state.- Static methods belong to the class, not instances.
- ES Modules provide native, statically-analyzable imports/exports, enabling tree-shaking and better tooling.
- Keep classes small and favor composition; avoid deep inheritance chains.
- Understand the difference between CommonJS and ESM before mixing them in a project.
Mastering classes and modules gave me the structural foundation to build applications that scale well beyond a single file, and understanding what’s happening underneath the syntax has made me a much more confident JavaScript developer.