I3 · Part 2 · Intermediate
Prototypes & OOP
class is real syntax now — but it's still prototypes underneath, every time.
11On this page
- The prototype chain
- See the lookup walk
- What new actually does
- class — the same four steps, with real syntax
- Private methods, and checking for a private field
- Static blocks — setup that runs once, per class
- new.target — knowing whether new was actually used
- Extending a built-in, safely
- Object.create(null) — an object with no prototype at all
- Symbol.hasInstance — instanceof is not magic either
- Composition vs inheritance, and mixins
The prototype chain
Every object has an internal link to another object — its
prototype — and property lookup that doesn't find a match
walks that link outward, exactly like the scope chain walked outward
in the last chapter. The chain ends at null.
rex (a Dog instance)
name"Rex"
breed"Labrador"
no "speak" here → check its prototype ↓
Dog.prototype
speakƒ ()
found it — lookup stops here
Object.prototype
toStringƒ ()
the chain's final stop before null
⚠ __proto__ and .prototype are not the same thing
obj.__proto__ (or the modern
Object.getPrototypeOf(obj)) is the link an
instance follows. Dog.prototype is a plain
object that becomes that link for every instance
new Dog() creates. A function has a
.prototype property; an object has a
__proto__ link. They're related, never interchangeable.
function Animal(name) { this.name = name; }
Animal.prototype.speak = function () { return this.name + " makes a sound"; };
const rex = new Animal("Rex");
Object.getPrototypeOf(rex) === Animal.prototype; // true
rex instanceof Animal; // true — checks exactly this chain
rex.hasOwnProperty("name"); // true — set directly on rex
rex.hasOwnProperty("speak"); // false — it's on the prototype, not rex itself
See the lookup walk
Watch the lookup walk. JavaScript does not copy methods onto objects; it walks a chain until it finds one, and stops at the first hit.
Lookup walk
What each level owns
Dry run: looking up dog.speak()
| Step | Object checked | What it owns | Has "speak"? |
|---|---|---|---|
| 1 | dog | name, breed | no — follow [[Prototype]] |
| 2 | Dog.prototype | fetch | no — follow [[Prototype]] again |
| 3 | Animal.prototype | speak | yes — lookup stops here, this stays bound to dog |
Three separate objects, one method call — speak is never
copied onto dog or onto Dog.prototype. Had
Animal.prototype not had it either, the walk would
continue to Object.prototype, then to null,
and only then return undefined instead of throwing.
What new actually does
new Fn(...) is four steps, always, whether
Fn is an old-style constructor function or a modern
class:
- A brand-new, empty object is created.
- Its internal prototype link is set to
Fn.prototype. Fnruns withthisbound to that new object (the "new" row from the this-binding table).- If
Fnexplicitly returns an object (a function counts), that's the result instead — otherwise the new object from step 1 is returned automatically.
function myNew(Ctor, ...args) {
const obj = Object.create(Ctor.prototype); // steps 1 & 2
const result = Ctor.apply(obj, args); // step 3
const returnedObject = (typeof result === "object" && result !== null) || typeof result === "function";
return returnedObject ? result : obj; // step 4
}
function Dog(name) { this.name = name; }
Dog.prototype.speak = function () { return this.name + " barks"; };
const rex = myNew(Dog, "Rex");
console.log(rex.speak(), rex instanceof Dog); // what happens?
"Rex barks" true — a hand-rolled new that
behaves identically to the real keyword, because those are genuinely
all four steps it performs. It's worth building once, because it
turns "new is magic" into "new is Object.create plus a
function call plus a return-value check."
class — the same four steps, with real syntax
class Shape {
static count = 0; // lives on the class itself, not on instances
#id; // private field — declared up front, "#" is part of the name
constructor(name) {
this.name = name;
this.#id = ++Shape.count;
}
get id() { return this.#id; } // getters/setters, same as plain objects
describe() {
return this.name + " #" + this.id;
}
static reset() { Shape.count = 0; } // called as Shape.reset(), never on an instance
}
class Circle extends Shape {
constructor(radius) {
super("Circle"); // MUST run before "this" is usable at all
this.radius = radius;
}
describe() {
return super.describe() + " (r=" + this.radius + ")"; // extend, don't just replace
}
}
const c1 = new Circle(5);
c1.describe(); // "Circle #1 (r=5)"
Rule Everything
class does is
still prototypes: methods land on Circle.prototype, not
on each instance, and extends just wires up the
prototype chain from the diagram above automatically.
class is real, enforced syntax on top of the exact same
machinery — not a different object model bolted on beside it.
⚠ #private is enforced by the parser, not by convention
c1.#id written outside the class body isn't a
runtime access-denied error — it's a
SyntaxError at parse time, because #id
simply isn't valid syntax anywhere the class hasn't declared it. It's
a much harder guarantee than the old _id
underscore-means-private convention, which was never actually
enforced by anything.
Private methods, and checking for a private field
# isn't only for fields — a method name can start with
# too, and the same parser-enforced privacy applies: it's
callable only from inside the class body, on any instance of that
class, not just this.
class Counter {
#count = 0;
#validate(n) { // private method — never visible outside
if (n < 0) throw new RangeError("count can't go negative");
}
add(n) {
this.#validate(n);
this.#count += n;
return this.#count;
}
static isCounter(obj) {
return #count in obj; // "ergonomic brand check" — true only for real instances
}
}
const c = new Counter();
c.add(5); // 5
Counter.isCounter(c); // true
Counter.isCounter({ count: 5 }); // false — a look-alike object, not a real instance
#count in obj is the blessed way to ask "does this
object actually have this private field" without triggering the
TypeError that obj.#count throws on a
non-instance. It's the closest JS gets to a true nominal type check —
duck typing can't fake it, because a plain object can never gain a
real #count slot from outside the class that declared it.
Static blocks — setup that runs once, per class
class Config {
static #data;
static { // runs exactly once, when the class is defined
try {
Config.#data = JSON.parse(readConfigFile());
} catch {
Config.#data = {};
}
}
static get(key) { return Config.#data[key]; }
}
Before static blocks, involved static setup meant either one static
field with an ugly inline expression, or an init()
static method every caller had to remember to invoke. A static block
runs automatically, can span multiple statements, and — because it
executes inside the class body — can read and write private static
fields that code outside the class never could.
new.target — knowing whether new was actually used
function Widget(name) {
if (!new.target) {
throw new TypeError("Widget() must be called with new");
}
this.name = name;
}
new Widget("ok"); // fine — new.target is Widget
Widget("oops"); // throws immediately instead of silently polluting the global this
new.target is undefined when a function is
called plainly, and holds the constructor actually invoked with
new otherwise — including inside a subclass's
super() call, where it's set to the subclass, not the
base. class constructors reject a missing
new automatically; new.target is the
mechanism that makes that possible, and it's occasionally useful
directly — an abstract-base-class pattern can throw specifically when
new.target === Shape itself, while still allowing
new Circle() to run Shape's constructor.
Extending a built-in, safely
class TrackedArray extends Array {
push(...items) {
console.log("pushing", items.length, "item(s)");
return super.push(...items);
}
}
const t = new TrackedArray(1, 2, 3);
t.push(4); // logs, then behaves exactly like a real array
t.map((n) => n * 2) instanceof TrackedArray; // true — array methods return the subclass
⚠ this only works because of real class semantics
Extending
Array (or Error, Map)
with old-style function/prototype code never
fully worked — the internal exotic behavior (an array's live
.length, an error's .stack) can't be faked
by manually wiring up a prototype chain the way the earlier
myNew demo did. Real class ... extends
delegates object creation to the built-in constructor itself, which is
the only thing that can produce a genuinely exotic object — one of the
few things class can do that the older syntax structurally
could not.
Object.create(null) — an object with no prototype at all
const dict = Object.create(null);
dict.toString; // undefined — no Object.prototype to inherit it from
dict.hasOwnProperty; // undefined too
const normal = {};
normal.toString; // ƒ () — inherited from Object.prototype, as always
Every object literal implicitly inherits from
Object.prototype — that's where toString,
hasOwnProperty and valueOf actually come
from. Object.create(null) skips that, producing a truly
bare object: no inherited methods, and no risk of a data key
colliding with one — a user-controlled key literally named
"toString" or "__proto__" is completely
inert here. That's why a hand-rolled lookup table sometimes reaches
for this instead of {} — though a real Map
(from the next chapter) is usually the safer default for keys you
don't fully control.
Symbol.hasInstance — instanceof is not magic either
class Even {
static [Symbol.hasInstance](value) {
return typeof value === "number" && value % 2 === 0;
}
}
console.log(4 instanceof Even); // what happens?
console.log(5 instanceof Even); // what happens?
true, then false — instanceof
doesn't hard-code "walk the prototype chain"; it calls
Symbol.hasInstance on the right-hand side when one
exists, and only falls back to the ordinary prototype-chain walk
otherwise. This is the same well-known-symbol mechanism the
metaprogramming chapter covers for iteration and coercion —
instanceof, for...of, and
template-literal-to-string are all operators with a symbol-shaped
escape hatch, not fixed language rules.
Composition vs inheritance, and mixins
extends models "is-a" — a Circle
is a Shape. Composition models "has-a" or
"can-do" — building an object out of smaller pieces it holds or uses,
rather than a class it descends from. Deep inheritance chains tend to
get brittle (change a base class, every descendant feels it); most
modern guidance leans composition first, inheritance only for a
genuinely stable, narrow "is-a" relationship.
A mixin is the middle ground: a function that takes a base class and returns a new one with extra behavior bolted on — reusable across classes that don't otherwise share a family tree.
const Serializable = (Base) => class extends Base {
serialize() { return JSON.stringify(this); }
};
class Point {
constructor(x, y) { this.x = x; this.y = y; }
}
class SerializablePoint extends Serializable(Point) {}
const p = new SerializablePoint(1, 2);
console.log(p.serialize()); // what happens?
{"x":1,"y":2} — Point never mentions
serialization at all. Serializable(Point) returns a
brand-new anonymous class that extends Point, so
SerializablePoint gets both its own fields and the
mixed-in method, and the same Serializable mixin could
wrap any other base class exactly the same way.
Interview answer, one sentence
"Every object delegates missing properties to its prototype, forming a chain the lookup walks; new creates an object linked to the constructor's prototype, runs the constructor with this bound to it and returns it, and class is syntax over exactly that, plus genuinely private fields."
Before you move on
- Trace a property lookup up a three-level prototype chain, naming exactly which object the search stops at and why.
- Write the four steps
newperforms from memory, without looking atmyNew. - Explain why
Dog.prototypeand an instance's__proto__are related but not interchangeable. - Say why extending
Arrayonly works correctly with realclass ... extends, not hand-wired prototypes. - Decide, for a concrete pair of types, whether composition or inheritance models their relationship better — and why.
Opens in the editor: write it, run it, and check it against real tests.
All 33 for this level →Finished reading?
0 of 41 chapters read in this series