🏛️ Classes & Modules
As programs grow, ad-hoc scripts stop scaling. Two ES6 features give JavaScript the structure it needs for real applications: classes, a clean syntax for defining objects and inheritance, and modules, a standard way to split code across files with explicit import/export. Together they turn a pile of scripts into a maintainable codebase.
Week 3 · Day 1 (Monday: ES6+ Features) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Define classes with a constructor, instance methods, and class fields
- Use
extendsandsuperto build an inheritance hierarchy - Add getters, setters, and
staticmembers where they fit - Enforce true encapsulation with private
#fields and methods - Split code into modules using named and default
export/import - Load code on demand with dynamic
import()and organize files with barrel re-exports
Estimated Time: 75 minutes
Practice: Build an encapsulated ShoppingCart class and split a small app into modules.
In This Lesson
Why Classes & Modules
Before ES6, organizing JavaScript meant constructor functions wired up through the prototype, and files that all shared one global namespace — a recipe for name collisions and fragile load-order dependencies. ES6 replaced both problems: classes give inheritance a readable syntax, and modules give every file its own private scope with explicit dependencies.
Let's take each feature in turn, starting with classes.
Classes: The Basics
A JavaScript class is a blueprint for creating objects that share the same shape and behavior. Under the hood it's still the same prototype system JavaScript always had — classes are "syntactic sugar" — but the syntax is far clearer. Here's the leap from the old constructor-function style to a class:
// Pre-ES6: constructor function + prototype method
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.greet = function () {
return `Hi, I'm ${this.name} (${this.age}).`;
};
// ES6 class — same result, clearer intent
class Person {
constructor(name, age) { // runs once, when you call `new Person(...)`
this.name = name;
this.age = age;
}
greet() { // shared by every instance, lives on the prototype
return `Hi, I'm ${this.name} (${this.age}).`;
}
}
const bob = new Person('Bob', 32);
console.log(bob.greet()); // "Hi, I'm Bob (32)."
💡 new in four steps
Calling new Person('Bob', 32) (1) creates a fresh empty object, (2) links it to Person.prototype, (3) runs the constructor with this pointing at that object, and (4) returns the object. Forgetting new is a classic bug — that's why classes throw if you call them without it.
Fields, Statics, Getters & Setters
Classes offer more than a constructor and methods. Class fields declare and initialize properties at the top. Static members belong to the class itself, not to instances. Getters and setters let a property be computed or validated behind a normal-looking access.
class User {
// Class fields — initialized on every new instance
role = 'user';
loginCount = 0;
constructor(username, email) {
this.username = username;
this.email = email; // goes through the setter below
this.createdAt = new Date();
}
// Instance method
login() {
this.loginCount++;
return `${this.username} logged in`;
}
// Static method — called on the class, not an instance
static compare(a, b) {
return a.loginCount - b.loginCount;
}
// Getter — accessed like a property: user.formattedDate
get formattedDate() {
return this.createdAt.toLocaleDateString();
}
// Setter — validates on assignment: user.email = '...'
set email(value) {
if (!value.includes('@')) throw new Error('Invalid email format');
this._email = value.toLowerCase();
}
get email() {
return this._email;
}
}
const alice = new User('alice', 'Alice@Example.com');
console.log(alice.login()); // "alice logged in"
console.log(alice.email); // "alice@example.com" (lowercased by setter)
console.log(User.compare(alice, alice)); // 0 — static, called on the class
Inheritance with extends
A class can extend another to inherit its fields and methods, then add or override its own. Inside the subclass constructor you must call super(...) — which runs the parent constructor — before touching this. You can also call super.method() to reuse the parent's version of an overridden method.
class Vehicle {
constructor(make, model, year) {
this.make = make;
this.model = model;
this.year = year;
}
toString() {
return `${this.year} ${this.make} ${this.model}`;
}
}
class Car extends Vehicle {
constructor(make, model, year, doors) {
super(make, model, year); // must run before using `this`
this.doors = doors;
}
// Override, but reuse the parent's work via super
toString() {
return `${super.toString()}, ${this.doors}-door`;
}
honk() { return 'Beep beep!'; }
}
const car = new Car('Toyota', 'Camry', 2022, 4);
console.log(car.toString()); // "2022 Toyota Camry, 4-door"
console.log(car.honk()); // "Beep beep!"
Private Fields & Methods
For years JavaScript faked "private" with underscore naming conventions that anyone could ignore. ES2022 made privacy real: any field or method prefixed with # is accessible only inside the class body. Touch it from outside and you get a syntax error, not a silent leak.
class BankAccount {
#balance = 0; // truly private
#transactions = [];
constructor(owner, initialDeposit = 0) {
this.owner = owner;
if (initialDeposit > 0) this.deposit(initialDeposit);
}
deposit(amount) {
if (amount <= 0) throw new Error('Deposit must be positive');
this.#balance += amount;
this.#record('deposit', amount);
return this.#balance;
}
withdraw(amount) {
if (amount > this.#balance) throw new Error('Insufficient funds');
this.#balance -= amount;
this.#record('withdrawal', amount);
return this.#balance;
}
getBalance() { return this.#balance; }
// Private method — an internal helper the outside world can't call
#record(type, amount) {
this.#transactions.push({ type, amount, date: new Date() });
}
}
const account = new BankAccount('Alice', 1000);
account.deposit(500);
console.log(account.getBalance()); // 1500
// console.log(account.#balance); // ❌ SyntaxError — private field
// account.#record('hack', 1e9); // ❌ SyntaxError — private method
✅ Why encapsulation matters
Making #balance private guarantees it only ever changes through deposit/withdraw, where the rules live. No outside code can set it to a negative number or skip a transaction record. That's the whole point of a class: bundle data with the only code allowed to change it.
Module Syntax
An ES module is just a .js file that uses export and import. Everything in a module is private to that file unless you explicitly export it. There are two flavors of export: named (as many as you like) and default (at most one per file, for the module's main thing).
// math.js — exporting
export function add(a, b) { return a + b; } // named export
export function subtract(a, b) { return a - b; } // named export
export const PI = 3.14159; // named export
function multiply(a, b) { return a * b; }
export { multiply }; // export after declaration
export { multiply as times }; // export with a rename
export default function area(radius) { // the ONE default export
return PI * radius * radius;
}
// app.js — importing
import { add, subtract, PI } from './math.js'; // named imports (names must match)
import { add as sum } from './math.js'; // rename on import
import area from './math.js'; // default import (name is yours)
import area, { multiply } from './math.js'; // default + named together
import * as MathUtils from './math.js'; // everything as a namespace object
console.log(add(2, 3)); // 5
console.log(area(5)); // 78.53975
console.log(MathUtils.PI); // 3.14159
⚠️ Modules need type="module"
In the browser, load your entry file with <script type="module" src="./app.js"></script>. Modules are deferred by default, always run in strict mode, and require the .js extension in relative paths. In Node, use .mjs or set "type": "module" in package.json.
Organizing Modules
A well-structured project groups modules by responsibility. A common convention is one clear job per file, folders by concern, and a single entry point that wires everything together.
project/
├── src/
│ ├── index.js # entry point
│ ├── models/
│ │ └── User.js
│ ├── services/
│ │ ├── userService.js
│ │ └── http.js
│ └── utils/
│ ├── math.js
│ └── validation.js
├── index.html
└── package.json
A barrel file (an index.js that re-exports a folder's public API) lets consumers import from one place instead of reaching into individual files:
// src/utils/index.js — the barrel
export * from './math.js';
export * from './validation.js';
// Elsewhere, one tidy import instead of two:
import { add, isEmail } from './utils/index.js';
Here's a small end-to-end example combining classes and modules — a model, a service that owns the data, and an entry point that uses the service:
// src/models/User.js
export class User {
constructor(id, name, email) {
this.id = id; this.name = name; this.email = email;
}
getDisplayName() { return this.name || this.email.split('@')[0]; }
}
// src/services/userService.js
import { User } from '../models/User.js';
const users = [];
export function addUser({ id, name, email }) {
const user = new User(id, name, email);
users.push(user);
return user;
}
export function getAllUsers() { return [...users]; }
// src/index.js
import { addUser, getAllUsers } from './services/userService.js';
addUser({ id: 1, name: 'John Doe', email: 'john@example.com' });
console.log(getAllUsers().map(u => u.getDisplayName())); // ['John Doe']
Dynamic Imports
The import ... from statements above are static: they run when the module loads. Sometimes you'd rather load a module only when it's actually needed — a heavy chart library, a page that most users never visit. Dynamic import() is a function that returns a promise for the module, so you can load code on demand and keep your initial bundle small.
// Loads the module only when this function runs
async function calculate() {
const math = await import('./math.js'); // returns a promise
console.log(math.add(5, 10)); // 15
console.log(math.default(7)); // the default export
}
// Great for code-splitting by route or feature
async function loadPage(pageName) {
try {
const page = await import(`./pages/${pageName}.js`);
page.default.init();
} catch (err) {
console.error(`Failed to load ${pageName}:`, err);
}
}
💡 Where you'll meet this
Frameworks lean on dynamic import for "lazy loading." React's React.lazy(() => import('./HeavyChart')) and Vite/webpack code-splitting are all built on exactly this syntax. Bundlers see the import() and automatically split that module into its own file.
Practice & Quiz
🏋️ Exercise 1: An encapsulated ShoppingCart
Goal: Write a ShoppingCart class with a private #items array. It should support addItem(product, qty) (increment quantity if the product is already present), a totalPrice getter, and expose items read-only via a getter that returns a copy.
const laptop = { id: 1, name: 'Laptop', price: 999 };
// cart.addItem(laptop, 2); cart.totalPrice → 1998
💡 Hint
Store { product, quantity } objects in #items. In addItem, find a matching product.id and bump its quantity, else push a new entry. The getter should return [...this.#items] so callers can't mutate the private array.
✅ Solution
class ShoppingCart {
#items = [];
addItem(product, quantity = 1) {
const existing = this.#items.find(i => i.product.id === product.id);
if (existing) existing.quantity += quantity;
else this.#items.push({ product, quantity });
}
get items() {
return [...this.#items]; // read-only copy
}
get totalPrice() {
return this.#items.reduce((sum, i) => sum + i.product.price * i.quantity, 0);
}
}
const cart = new ShoppingCart();
cart.addItem(laptop, 2);
console.log(cart.totalPrice); // 1998
🏋️ Exercise 2: Split into modules
Goal: Given a single-file app, split it into a Task model module and a taskService module, then import both into main.js. The service should keep tasks in a private array and expose createTask and getAllTasks.
💡 Hint
export class Task { ... } in one file; import { Task } from './Task.js' in the service; export a singleton with export default new TaskService() if you want one shared instance.
✅ Solution
// Task.js
export class Task {
constructor(id, title, status = 'pending') {
this.id = id; this.title = title; this.status = status;
}
complete() { this.status = 'completed'; }
}
// taskService.js
import { Task } from './Task.js';
class TaskService {
#tasks = [];
#nextId = 1;
createTask(title) {
const task = new Task(this.#nextId++, title);
this.#tasks.push(task);
return task;
}
getAllTasks() { return [...this.#tasks]; }
}
export default new TaskService();
// main.js
import taskService from './taskService.js';
taskService.createTask('Learn modules');
console.log(taskService.getAllTasks());
🎯 Quick Quiz
Question 1: In a subclass constructor, what must you do before using this?
Question 2: How many default exports may a single module have?
Question 3: What does a #-prefixed field give you?
Best Practices & Pitfalls
✅ Do
- Give each module a single, clear responsibility
- Use a default export for a module's one main thing, named exports for the rest
- Make internal state private with
#and expose it through methods/getters - Call
super(...)first in any subclass constructor - Reach for dynamic
import()to lazy-load heavy or rarely-used code
❌ Don't
- Use an arrow function for a class method that relies on
thisbeing the instance's prototype method - Export mutable
letbindings that can change unexpectedly — preferconst - Cram unrelated helpers into one giant
utils.js - Forget the
.jsextension in relative module paths in the browser - Rely on underscore-prefixed "private" properties — use real
#privacy
⚠️ Classes are sugar, not a new object model
JavaScript classes still use prototypes underneath. There's no method overloading, and there were no access modifiers until # privacy arrived. Coming from Java or C#, expect familiar syntax but prototype-based semantics — for example, a class is a first-class value you can pass around and store in a variable.
Summary
🎉 Key Takeaways
- A class bundles data (fields) and behavior (methods) behind one name;
newbuilds instances - Static members live on the class; getters/setters compute or validate property access
extends+supergive you clean single inheritance#private fields and methods provide real encapsulation- Modules have private scope; you share code via named and default exports
- Dynamic
import()loads modules on demand for code-splitting and lazy loading
📚 Additional Resources
🚀 What's Next?
You can now write structured, modular code — but real projects need a way to install and manage third-party modules. Next up: Introduction to NPM, the Node package manager that pulls the whole ecosystem into your project.
🎉 Excellent work!
Classes and modules are the organizational backbone of every serious JavaScript app you'll build.