Skip to main content

🏛️ 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 extends and super to build an inheritance hierarchy
  • Add getters, setters, and static members 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.

graph TD A[Pre-ES6] --> B[Prototype wiring by hand] A --> C[One shared global scope] A --> D[Manual script ordering] E[ES6+] --> F[Clear class syntax] E --> G[Module scope, private by default] E --> H[Explicit import / export]

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.

A Person class box listing its fields and its greet method Person + name : string + age : number + constructor(name, age) + greet()
A class groups related data (fields) and behavior (methods) behind one name.

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!"
classDiagram Vehicle <|-- Car Vehicle <|-- Motorcycle Vehicle : +make Vehicle : +model Vehicle : +year Vehicle : +toString() Car : +doors Car : +honk() Motorcycle : +wheelie()

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 this being the instance's prototype method
  • Export mutable let bindings that can change unexpectedly — prefer const
  • Cram unrelated helpers into one giant utils.js
  • Forget the .js extension 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; new builds instances
  • Static members live on the class; getters/setters compute or validate property access
  • extends + super give 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.