gap·map

Prototypes & classes

[ junior depth ]

How property lookup follows the prototype chain

Every object carries one hidden link, [[Prototype]], pointing at another object or null. Reading obj.x checks obj's own properties first; on a miss it follows the link, and keeps following until it finds x or hits null (then you get undefined). That single rule is the whole inheritance model. Nothing is ever copied; the walk happens fresh at every access.

const animal = { eats: true };
const rabbit = Object.create(animal); // rabbit → animal → Object.prototype → null
rabbit.eats;  // true — found one hop up
rabbit.jumps; // undefined — walked to null

this doesn't care where the method was found: in rabbit.eat(), this is rabbit even if eat lives three links up. Methods get shared through the chain while state stays per-object.

prototype vs __proto__ explained

The most common tangle in this topic:

  • obj.__proto__ (properly: Object.getPrototypeOf(obj)) reads the object's own [[Prototype]] link, the place where its lookups go.
  • F.prototype is an ordinary property that only functions have. It is not F's own prototype but the object that instances of new F() will link to.
function Dog(name) { this.name = name; }
Dog.prototype.bark = function () { return `${this.name}!`; };

const d = new Dog("Rex");
Object.getPrototypeOf(d) === Dog.prototype; // true — the meeting point
d.bark(); // miss on d → found on Dog.prototype, this = d

❌ "d.prototype points to the prototype." d has no .prototype property at all. Only functions do, and it describes what their future instances will link to.

What the new operator does

Four steps: (1) create a blank object; (2) set its [[Prototype]] to F.prototype; (3) run the function body with this bound to that object; (4) return the object, unless the body explicitly returns some other object, which then replaces it (returning a primitive is silently ignored).

Every function gets a default F.prototype of { constructor: F }, which is why d.constructor === Dog works out of the box.

What the class keyword builds

class Dog { bark() {...} } builds exactly the pair above: a constructor function plus methods placed on Dog.prototype. There is no new object model underneath. What class does add is stricter rules: calling Dog() without new throws, the body runs in strict mode, class declarations aren't usable before their line, and methods are non-enumerable (they won't show up in for..in).

❌ "The class copies its methods into each instance." One bark function exists, on the prototype, and a million dogs share it through their links. Because lookup is live, editing that prototype later reaches every dog already constructed; the middle notes cover that, along with what assignment to an inherited name does.

[ middle depth ]

How assignment shadows prototype properties

The chain is consulted for reading. Assignment puts an own property directly on the object, shadowing whatever sits above it without modifying anything up the chain:

const proto = { greet() { return "hi"; } };
const obj = Object.create(proto);
obj.greet = () => "yo"; // own property; proto untouched
delete obj.greet;       // un-shadows
obj.greet();            // "hi" again

One exception: if the chain holds an accessor with a setter, assignment triggers that setter instead of creating an own property.

The flip side: because lookup is live, mutating F.prototype reaches instances that already exist. Add Dog.prototype.sit = ... and every old dog can sit. Replacing it (Dog.prototype = {...}) doesn't: old instances keep their original link, and you've also dropped the default constructor property. ❌ "Existing objects snapshot their methods at construction." Nothing is snapshotted; the walk happens at call time.

How instanceof works

x instanceof C walks x's chain asking one narrow question: is the object currently stored in C.prototype in here? It checks identity, not lineage. Consequences: reassign C.prototype and existing instances stop matching; an object from another iframe fails instanceof Array because that realm has a different Array.prototype (use Array.isArray); and a class can override the whole check via Symbol.hasInstance. The direct form of the question is proto.isPrototypeOf(x).

Class fields vs prototype methods

class Counter {
  count = 0;
  inc = () => this.count++;  // FIELD: own property, initializer re-runs per new
  dec() { this.count--; }    // METHOD: one shared function on Counter.prototype
}
new Counter().inc === new Counter().inc; // false
new Counter().dec === new Counter().dec; // true

The arrow-field buys a permanently bound this (you can pass counter.inc around freely) and pays with one closure per instance, no super, and a function the prototype can't see, so it's harder to patch or spy in one place. Initialization order matters under inheritance: base fields → base constructor → derived fields → derived constructor. So a parent constructor calling an overridden method gets the child's version, but reading an overridden field sees the parent's value, because the child's initializer hasn't run yet.

How #private fields behave

_underscore is a handshake; # is enforced. The rules: a #name is only syntactically legal inside the class body that declared it. Outside, it's a SyntaxError before the code even runs. Class code reading obj.#x on an object that lacks the field throws a TypeError instead of returning undefined. There's no obj["#x"] and no dynamic access of any kind. Subclasses cannot see parent #fields; expose getters if they need to. Private methods live off-prototype. The one safe probe is the brand check:

class Wallet {
  #cents = 0;
  static isWallet(o) { return #cents in o; } // true only for real instances
}

❌ "Missing private field reads give undefined like normal properties." They throw, and that throw-versus-walk difference is what makes # a reliable brand. Why engines care about chain stability (shapes, caches, when prototype tricks get expensive) is in the senior notes.

[ senior depth ]

Hidden classes and inline caches

Engines group objects by shape (hidden class): same properties, added in the same order. Property access compiles into inline caches that say "for this shape, the value is at offset N, or on that exact prototype." The bet pays off only while shapes and chains hold still. What breaks it: Object.setPrototypeOf / __proto__ = on a live object (a megamorphic deopt, one of the few things spec notes and engine docs both flag), adding properties in different orders across instances, and mutating a prototype that hot code has already cached. The discipline is boring: build the full chain at creation time, treat prototypes as immutable after setup, initialize every field in the constructor so all instances share one shape. Prototype mutation in module-load code is fine; in a render loop it's a fire.

Null-prototype objects

{} secretly ships toString, constructor, and a live __proto__ accessor, which is how prototype pollution works: attacker-controlled keys like "__proto__" write through to Object.prototype. A dictionary keyed by user input should be Object.create(null) (or a { __proto__: null } literal, which is creation-time syntax rather than the deprecated accessor): it inherits nothing, and "__proto__" becomes an ordinary key. The cost: dict.hasOwnProperty(k) throws, since there's nothing to inherit it from, so use Object.hasOwn(dict, k). The same reasoning makes Object.prototype.hasOwnProperty.call(x, k) the defensive form when x is untrusted. The honest answer to "when do I want this over a Map": Map usually wins, and null-proto objects earn their keep for JSON-shaped data and hot string-keyed lookups.

When to use extends vs composition

Deep extends chains are the classic regret: single inheritance forces taxonomy decisions early, base-class edits ripple into every descendant, and behavior gets smeared across five files. Default to composition; an object holding capabilities beats an object being a taxonomy. extends still earns its keep for genuine is-a with framework contracts: custom Error types, HTMLElement for web components, a library's mandated base class.

Subclassing built-ins is its own minefield. class Stack extends Array works natively, but stack.map(...) returns a Stack, because built-in methods construct their result via Symbol.species (override static get [Symbol.species]() { return Array; } to opt out). extends Error needs this.name = "MyError" set manually. And ES5-transpiled output breaks built-in subclassing entirely; Reflect.construct is the workaround, or don't transpile classes.

Where prototypes surface in working code

  • Polyfills. The one legitimate native-prototype patch, always guarded: if (!Array.prototype.at) { ... }. Unguarded extensions are how SmooshGate happened (a popular library's flatten forced TC39 to rename the standard method).
  • Borrowing. Array.prototype.slice.call(arrayLike): generic methods only require the right shape of this, so array-likes can rent them. Mostly superseded by Array.from and spread, but you'll read it in older code, and interviewers still ask what it does.
  • Spec-grade checks. Array.isArray over instanceof Array (realms), Object.prototype.toString.call(x) when you need the internal tag, and #field in obj as an unforgeable brand check that no Symbol.hasInstance trick can spoof.
  • new.target. undefined when a function is called without new; the honest way to build abstract base classes (if (new.target === Base) throw ...) or dual-mode constructors.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

unlocks

more JavaScript