Section 5 · Writing Real Programs
Classes
16 min3 min readCode playground
Lesson at a glance
Define classes with a constructor, methods, getters and private fields, create independent instances with new, and avoid the classic lost-this bug when passing methods as callbacks.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Look at what Pocket’s code does with its expenses: a variable holds the array, and a dozen functions each take that array, do something, and return a result. The data and the operations on it are separate, so nothing stops a stray line of code from pushing a malformed expense straight into the array. A class bundles data with the functions allowed to work on it, and can make the data itself unreachable from outside.
Defining a class
class ExpenseStore {
#expenses = [];
#nextNumber = 1;
constructor(initial = []) {
for (const expense of initial) {
this.add(expense);
}
}
add({ label, amount, category = "other" }) {
const expense = { id: `exp-${this.#nextNumber++}`, label, amount, category };
this.#expenses.push(expense);
return expense;
}
remove(id) {
this.#expenses = this.#expenses.filter((e) => e.id !== id);
}
get total() {
return this.#expenses.reduce((sum, e) => sum + e.amount, 0);
}
list() {
return [...this.#expenses];
}
}
const store = new ExpenseStore([{ label: "Coffee", amount: 450, category: "food" }]);
store.add({ label: "Train", amount: 1220, category: "travel" });
console.log(store.total, store.list().map((e) => e.id));There is a lot here, so take it one piece at a time.
#expenses = [] declares a private field. Every instance gets its own #expenses, and the # makes it private: only code inside the class body can read or write it. Outside, store.#expenses is a syntax error. Anyone who wants to change the list has to go through add and remove, which guarantee every expense gets an id and a category.
constructor is a special method that runs when you write new ExpenseStore(...). Its job is to set the new object up. Here it adds any initial expenses through add, so they follow the same rules.
add, remove and list are methods. Inside a method, this refers to the object the method was called on: in store.add(...), this is store.
get total() is a getter. You read it like a property, store.total, without parentheses, but it runs code each time. A total stored in a field could go stale when an expense is removed; a getter is always computed from the current data.
list() returns a copy, [...this.#expenses], not the array itself. Returning the real array would let callers push into it and undo all the protection.
Instances: one blueprint, many objects
new creates a fresh object, runs the constructor with this pointing at it, and returns it. Each instance has its own fields; the methods are defined once on the class and shared.
class ExpenseStore {
#expenses = [];
add(expense) { this.#expenses.push(expense); }
get count() { return this.#expenses.length; }
}
const home = new ExpenseStore();
const work = new ExpenseStore();
home.add({ label: "Groceries", amount: 6400 });
console.log(home.count, work.count);If this reminds you of createBudget from the closures lesson, it should. A closure and a class with private fields solve the same problem, data that only certain functions can touch. Closures are lighter for a single function; classes are clearer when several operations share the same data.
The lost this
this is not fixed when you write a method. It is decided each time the function is called, by what is to the left of the dot. store.add(x) calls add with this set to store. But if you pass the method somewhere on its own, there is no dot when it is eventually called:
class Greeter {
name = "Pocket";
hello() {
return `Hello from ${this.name}`;
}
}
const g = new Greeter();
console.log(g.hello());
const detached = g.hello;
try {
console.log(detached());
} catch (error) {
console.log("Error:", error.message);
}
const wrapped = () => g.hello();
console.log(wrapped());The first call works because g is to the left of the dot. detached() is called with nothing to the left of a dot, so inside it this is undefined (class bodies always run in strict mode), and reading this.name throws.
Classes can also build on other classes with extends, inheriting their methods and adding new ones. You will use that once, in the next lesson, to make your own error type. Beyond that, a good default for application code is to prefer small classes and plain functions over deep inheritance chains, which tend to be hard to change.
Your store now refuses bad changes from outside. The next lesson is about what happens when something goes wrong anyway: errors, how to throw and catch them on purpose, and how to hunt down bugs with the browser DevTools.
Key takeaways
- A class is a blueprint;
new ClassName(...)runs the constructor and returns a new instance with its own data. - Methods are shared by all instances, and inside them
thisis the instance the method was called on. - Private fields (
#expenses) can only be read inside the class body, which protects an object’s data the way closures do. - A getter (
get total()) is read like a property but computed each time, so derived values never go stale. - Passing
store.addas a callback losesthis; wrap it in an arrow function,(e) => store.add(e).