Skip to main content
Adapted, not copied

Original notes for JavaScript360.org — topic order inspired by Eloquent JavaScript (4th ed.) by Marijn Haverbeke. See the full attribution note.

Chapter 6: The Secret Life of Objects

Estimated time: ~50 minutes · Chapter type: Concept

What you'll learn

  • What "prototype-based" object orientation actually means under the hood
  • Modern class syntax: constructors, methods, inheritance, private fields
  • Getters, setters, and static members
  • How to make your own objects work with for...of

Abstract data types

An abstract data type bundles data with the operations that make sense on it, and hides the internal representation so callers don't depend on details that might change. A Stack is a classic example: callers only need push, pop, and peek — they shouldn't care whether it's backed by an array or a linked list underneath.

class Stack {
#items = []; // private field — inaccessible from outside the class, see below

push(item) {
this.#items.push(item);
}
pop() {
return this.#items.pop();
}
peek() {
return this.#items.at(-1);
}
get size() {
return this.#items.length;
}
}

const undoStack = new Stack();
undoStack.push('draw circle');
undoStack.push('draw square');
undoStack.pop(); // "draw square"

Prototypes

Here's the part of JavaScript that genuinely differs from most languages you may have used before: JavaScript objects don't get their behavior from a rigid "class blueprint" the way Java or C# objects do. Instead, every object has an internal link to another object — its prototype — and when you access a property JavaScript doesn't find directly on the object, it looks up the prototype chain until it finds it (or reaches the end, returning undefined).

const animal = {
describe() {
return `A ${this.type} says ${this.sound}`;
},
};

const dog = Object.create(animal); // dog's prototype is animal
dog.type = 'dog';
dog.sound = 'woof';

dog.describe(); // "A dog says woof" — describe() isn't on dog, so JS checks its prototype

Every plain object you create with {} automatically gets Object.prototype as its prototype (unless you opt out), which is where common methods like toString() live. class syntax, covered next, is mostly a cleaner way of setting up this same prototype chain — it doesn't replace it.

Classes

class is syntax sugar over the prototype system: methods you define in a class body are automatically placed on the prototype shared by every instance, so they're stored once, not duplicated per object.

class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}

area() {
return this.width * this.height;
}
}

const r = new Rectangle(4, 5);
r.area(); // 20

new Rectangle(4, 5) creates a fresh object, runs constructor with this bound to it, and returns the result. area lives once on Rectangle.prototype and is shared by every Rectangle instance — creating a thousand rectangles doesn't create a thousand copies of area.

Private fields

Fields (and methods) prefixed with # are genuinely private — accessible only from inside the class body, not from outside code, and not even visible via Object.keys or for...in:

class BankAccount {
#balance;

constructor(openingBalance) {
this.#balance = openingBalance;
}

deposit(amount) {
if (amount <= 0) throw new Error('Deposit must be positive');
this.#balance += amount;
}

get balance() {
return this.#balance;
}
}

const acc = new BankAccount(100);
acc.deposit(50);
acc.balance; // 150
// acc.#balance; // SyntaxError outside the class — truly private, not just a convention

This is a genuine language feature (not just a naming convention like a leading underscore), and it's the standard way to enforce encapsulation in modern JavaScript.

Getters, setters, and statics

Getters and setters let a property look like a plain field from the outside while actually running code — useful for validation or computed values:

class Temperature {
#celsius;
constructor(celsius) {
this.#celsius = celsius;
}
get fahrenheit() {
return this.#celsius * 9/5 + 32;
}
set fahrenheit(value) {
this.#celsius = (value - 32) * 5/9;
}
}

const t = new Temperature(0);
t.fahrenheit; // 32 — reads like a property, runs code underneath
t.fahrenheit = 212;

Static members belong to the class itself, not to instances — useful for factory functions or constants tied to the concept, not any particular object:

class Point {
static origin = new Point(0, 0);
constructor(x, y) { this.x = x; this.y = y; }
static fromArray([x, y]) { return new Point(x, y); }
}

Point.fromArray([3, 4]); // Point { x: 3, y: 4 }

Inheritance

extends lets one class build on another, inheriting its methods and adding or overriding its own — super(...) calls the parent constructor, and super.method() calls the parent's version of an overridden method:

class Shape {
describe() {
return `A shape with area ${this.area()}`;
}
}

class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
}

class LabeledCircle extends Circle {
constructor(radius, label) {
super(radius);
this.label = label;
}
describe() {
return `${this.label}: ${super.describe()}`;
}
}

new LabeledCircle(2, 'Wheel').describe();
// "Wheel: A shape with area 12.566370614359172"

Overriding derived properties, and instanceof

Any method a subclass defines with the same name as a parent method shadows it — JavaScript finds the closer one on the prototype chain first (LabeledCircle.describe above shadows Shape.describe, though it chooses to call the original via super.describe()).

instanceof checks whether an object's prototype chain includes a given class's prototype:

new Circle(1) instanceof Shape;  // true — Circle's chain includes Shape.prototype
new Circle(1) instanceof Circle; // true
'hello' instanceof Shape; // false

Polymorphism

Polymorphism means different types of objects can respond to the same method call in their own way — you saw it above: Shape.describe() calls this.area() without caring which subclass's area actually runs. This is what lets you write code against a general interface (Shape) and have it correctly handle any specific subclass (Circle, Square, whatever else you add later) without modification.

Making your own objects iterable

for...of works on arrays, strings, and a few other built-ins because they implement the iterator protocol — an object with a Symbol.iterator method that returns an object with a next() method. You can implement this yourself to make any custom object work with for...of:

class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}

[Symbol.iterator]() {
let current = this.start;
const end = this.end;
return {
next() {
if (current <= end) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
},
};
}
}

for (const n of new Range(1, 5)) {
console.log(n); // 1, 2, 3, 4, 5
}

[...new Range(1, 3)]; // [1, 2, 3] — the spread operator also uses the iterator protocol

Symbol.iterator is a built-in Symbol — a unique, non-string value often used as a property key precisely to avoid clashing with any regular string property name a user might define.

Key takeaways

  • Every JavaScript object has a prototype it delegates missing property lookups to; class is syntax sugar over this same mechanism.
  • class, extends, super, getters/setters, static, and #private fields cover the vast majority of everyday object-oriented code.
  • Private fields (#field) are real language-level privacy, not just convention.
  • Implementing [Symbol.iterator] makes any object work with for...of and the spread operator.

Try it yourself

  1. Write a Queue class (first-in, first-out) with enqueue, dequeue, and a size getter, using a private field for storage.
  2. Create a class Animal with a speak() method, then two subclasses Cat and Dog that each override speak(). Put them in an array and call speak() on each in a loop — that's polymorphism in action.
  3. Add a [Symbol.iterator] method to your Queue from question 1 so it works with for...of without emptying the queue (hint: iterate over a copy of the internal array).
Hints
  1. Array.prototype.shift() removes and returns the first element — perfect for dequeue.
  2. Each subclass's speak() shadows Animal's; calling animal.speak() in a loop dispatches to whichever is correct for that instance automatically.
  3. [Symbol.iterator]() { return [...this.#items][Symbol.iterator](); } reuses the array's own built-in iterator on a shallow copy.

Summary

JavaScript's object system is more flexible — and a little stranger — than the classical OOP you might know from other languages, but class syntax gives you a comfortable, familiar surface over that flexibility. With functions, data structures, and objects covered, you now have every core language tool. Time to put them all to work on your first real build.

 

🌟 Join the JavaScript360 community

Connect with fellow JS developers, and get free interview prep, React challenges, and DSA guides.

Join on WhatsApp →