EduFlow
JavaScript Fundamentals
0%

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.

  1. 1Defining a class
  2. 2Instances: one blueprint, many objects
  3. 3The lost this

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

JavaScript
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.

The ExpenseStore class defines the methods add, remove, list and the total getter. Two instances, home and work, each have their own private expenses array, and both use the shared methods. class ExpenseStore add, remove, list get total home #expenses: 12 items work #expenses: 3 items new ExpenseStore()
One class, many instances with their own data
JavaScript
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:

JavaScript
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 this is 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.add as a callback loses this; wrap it in an arrow function, (e) => store.add(e).

Further reading

Check your understanding

3 questions

Question 1 of 3

What does this log?

JavaScript
class Counter {
  count = 0;
  increment() { this.count++; }
}
const a = new Counter();
const b = new Counter();
a.increment();
a.increment();
console.log(a.count, b.count);
Question 2 of 3

button.addEventListener("click", store.clear); makes clear throw a TypeError about the private field #expenses (“…from an object whose class did not declare it”) when the button is clicked. Why?

Question 3 of 3

An ExpenseStore keeps its list in #expenses. What does store.#expenses written outside the class do?

Practice

Code playground

A store that protects its data

Rewrite ExpenseStore so its data can only change through its methods:

  • Keep the expenses in a private field #expenses, so instances have no public properties.
  • add({ label, amount, category }) creates an expense with an id ("exp-1", "exp-2"… counted per store), a category defaulting to "other", stores it and returns it.
  • remove(id) removes the expense with that id.
  • list() returns a copy of the expenses array.
  • total is a getter returning the sum of all amounts.
  • totalFor(category) returns the sum for one category (0 if none).
// Pocket's data, unprotected: any code can push anything into it.
class ExpenseStore {
  constructor() {
    this.expenses = [];
  }

  add(expense) {
    this.expenses.push(expense);
  }
}

const store = new ExpenseStore();
store.add({ label: "Coffee", amount: 450, category: "food" });
console.log(store.expenses);
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • add returns the new expense with an id and a default category
  • list returns a copy, so changing it does not change the store
  • The store has no public data properties
  • remove deletes the expense with that id
  • total is a getter that stays up to date
  • totalFor sums one category
  • Two stores keep separate data and id counters