Skip to content

3.6.4 — Objects, Prototypes & Classes

3.6.13.6.3 built how JavaScript runs code. This page covers how it structures data and behavior — and it's the part of the language where JavaScript did something genuinely unusual. There are no classes underneath: JavaScript shares behavior through prototypes — objects inheriting directly from other objects — and the class syntax you write daily is a thin disguise over that machinery. We build the real model first (property descriptors, the prototype chain, Object.create, constructor functions), then unmask class feature by feature (fields, # privacy, statics, extends/super), and finish with how instanceof actually decides. Explain prototypes. [EQ-568]Explain the prototype chain. [EQ-569]

1. Properties are richer than they look: descriptors

An object is a collection of key–value pairs — but each property secretly carries three switches beside its value. Object.getOwnPropertyDescriptor reveals them:

javascript
const user = { name: "Ada" };
Object.getOwnPropertyDescriptor(user, "name");
// → { value: "Ada",
//     writable: true,       // may the value be assigned?
//     enumerable: true,     // does it show in for…in / Object.keys / spread?
//     configurable: true }  // may it be deleted or have these flags changed?

Normal assignment creates properties with all three flags true. Object.defineProperty lets you set them deliberately — which is how libraries make read-only or hidden properties:

javascript
Object.defineProperty(user, "id", {
  value: 42,
  writable: false,       // assignments now fail (silently sloppy, throwing strict)
  enumerable: false,     // invisible to Object.keys, JSON.stringify, spread
  configurable: false    // permanent: cannot delete, cannot re-configure
});
user.id = 99;            // strict mode: TypeError
Object.keys(user);       // → ["name"] — id hidden

Two everyday appearances of this machinery: why class methods don't show up when you loop an instance (they're defined non-enumerable on the prototype), and the object-locking utilities — Object.freeze(obj) (all properties non-writable + non-configurable, no additions: shallow immutability at runtime), Object.seal (no add/delete, values still writable), Object.preventExtensions (no additions only).

A property can also be computed on access — an accessor property with a getter/setter instead of a stored value:

javascript
const temp = {
  celsius: 25,
  get fahrenheit() { return this.celsius * 9/5 + 32; },   // computed on READ
  set fahrenheit(f) { this.celsius = (f - 32) * 5/9; }    // runs on WRITE
};
temp.fahrenheit;        // → 77      looks like data, runs code
temp.fahrenheit = 212;  // temp.celsius is now 100

Getters/setters are how libraries intercept property access — validation on write, lazy computation, deprecation warnings, and (famously) Vue 2's entire reactivity system, which wrapped your data's properties in getters/setters to know when anything read or wrote them.

2. The prototype chain: JavaScript's inheritance

Now the distinctive part. Every object has a hidden internal link to another object, called its prototype. Property lookup uses it: check the object's own properties; if absent, follow the link and check that object; keep following — the prototype chain — until found, or until the chain ends at null (then yield undefined).

dog — own properties{ name: "Rex" }Dog.prototype{ bark() } ← shared, onceObject.prototype → null{ toString(), hasOwnProperty… }not found? go upstill not? go up① dog.bark()② found here
Figure 1 — The prototype chain. Lookup walks upward until found or the chain ends at null. Shared methods live once on the prototype rather than being copied into every instance — a thousand dogs, one bark.

This is prototypal inheritance, and the difference from class-based languages is philosophical: in Java, a class is a blueprint and objects are stamped from it; in JavaScript, objects inherit from other objects — a prototype is just an ordinary object used as a fallback. Its virtues: methods exist once (memory), and because the chain is consulted at lookup time, adding a method to a prototype instantly equips every existing object linking to it. That's also how built-ins work — your array finds .map on Array.prototype — and why "monkey-patching" Array.prototype affects every array in the program (powerful; discouraged in shared code).

The plumbing APIs, so no incantation stays magic:

javascript
const animal = { eat() { return "eating"; } };
const dog = Object.create(animal);        // new object whose PROTOTYPE is animal
dog.eat();                                // → "eating" — found one link up
Object.getPrototypeOf(dog) === animal;    // → true
dog.hasOwnProperty("eat");                // → false — it's inherited, not own

Object.create(proto) is prototypal inheritance in its purest form — no constructors, no classes: "make me an object that falls back to this one." (You'll also meet __proto__, the legacy accessor for the same link — readable everywhere, but use the Object.* functions in new code.) Important asymmetry: only reads walk the chain. dog.name = "Rex" always creates/updates an own property on dog, shadowing any inherited one — inheritance is for lookup, never for assignment.

Two loop-related consequences: for…in iterates enumerable properties including inherited ones (one more reason it's rarely what you want — 3.6.6); Object.keys/values/entries and spread stick to own + enumerable.

3. Constructor functions: what new was built for

Before 2015, the idiom for "many objects sharing methods" was the constructor function — an ordinary function used with new (3.6.3 gave new's four steps; step 2 is the one that matters here: link the new object to the function's .prototype property):

javascript
function Dog(name) {          // a plain function, capitalized by convention
  this.name = name;           // per-instance data → own properties
}
Dog.prototype.bark = function () {      // shared behavior → ONE function object,
  return `${this.name} says woof`;      // on the prototype all instances link to
};

const rex = new Dog("Rex");
rex.bark();                   // → "Rex says woof"  (bark found via the chain;
                              //    this = rex by the implicit-binding rule)

Every function automatically owns a .prototype object for exactly this purpose (arrow functions don't — they can't construct). The division of labor is the heart of the model: data on instances, behavior on the prototype — and it's precisely what class automates.

4. class: familiar syntax, same machinery

ES6 added class — and it's essential to see it as syntactic sugar: friendlier notation over the constructor+prototype pattern above, not a new object model. The modern feature set in one annotated example:

javascript
class Dog {
  species = "canine";            // (1) field → own property per instance
  #trickCount = 0;               // (2) PRIVATE field — inaccessible outside
  static kingdom = "Animalia";   // (3) static → property of Dog itself

  constructor(name) { this.name = name; }

  bark() { return `${this.name} says woof`; }   // (4) → Dog.prototype, non-enumerable

  get tricks() { return this.#trickCount; }     // (5) accessor on the prototype
  learnTrick() { this.#trickCount++; }

  static compare(a, b) {         // (6) called as Dog.compare(...) — utility, no instance
    return a.tricks - b.tricks;
  }
}

class Puppy extends Dog {                   // (7) wires Puppy.prototype → Dog.prototype
  constructor(name) {
    super(name);                            // (8) run parent constructor FIRST
  }                                         //     (required before touching this)
  bark() {
    return super.bark() + " (squeakily)";   // (9) parent's method, current this
  }
}

Line by line: (1) fields are per-instance own properties, initialized at construction (3.6.3 covered the field-arrow idiom). (2) #trickCount is a genuinely private field — a syntax error to touch outside the class body, invisible to Object.keys, JSON, and even in checks (use the idiom #x in obj inside the class to brand-check). This is real language-level privacy — stronger than closures' convention and TypeScript's compile-time-only private (3.7.6). (3, 6) static members live on the constructor itself — namespaced utilities and constants (Dog.compare, Array.isArray is the built-in example). (4, 5) methods and accessors go on Dog.prototype, shared and non-enumerable — which is why looping an instance shows species but not bark. (7) extends links two chains: Puppy.prototype → Dog.prototype (instances inherit methods) and Puppy → Dog (statics inherit too). (8) a derived constructor must call super before using this — the parent builds the object first. (9) super.bark() calls the parent implementation with the current instance as this.

What did not change from section 3: Dog is still a function; bark still lives on the prototype; rex still finds it via the chain; this in methods still follows 3.6.3's dynamic rules (extracted methods still lose it). Three practical payoffs of knowing this: debugger and error output still speak prototypes; performance intuitions transfer (adding methods per-instance defeats the sharing); and the 3.5 advice — composition over inheritance — applies with extra force, since the object model composes naturally and deep extends chains inherit all of inheritance's usual costs.

5. instanceof, unmasked

With the chain in hand, instanceof stops being magic:

x instanceof Fn asks: does Fn.prototype appear anywhere in x's prototype chain?

javascript
rex instanceof Dog;      // Dog.prototype is rex's 1st link            → true
rex instanceof Object;   // Object.prototype is further up             → true
rex instanceof Array;    // Array.prototype is nowhere in rex's chain  → false

Consequences you can now derive: it works across extends (a Puppy is a Dog — the chain passes through both prototypes); it fails across realms (an array from an iframe or a Node vm has a different Array.prototype, so instanceof Array is false — the reason Array.isArray exists); it says nothing about shape (which is why TypeScript interfaces can't be instanceof-checked — 3.7.7); and it's customizable — a class can define static [Symbol.hasInstance](x) (3.6.6) to answer the question however it likes. Its cousins: typeof for primitives, Array.isArray for arrays, and duck-type/schema checks for shapes.

6. The expert lens

class being sugar over prototypes is a case study in "familiar syntax over an unfamiliar model." The syntax exists because millions of developers arriving from Java found prototypes alien, and adoption matters. But sugar leaks: this still binds dynamically, extends still wires prototype links, instanceof still walks chains, and debuggers still show .prototype. The general design lesson: a friendly façade over a different underlying model helps beginners and confuses experts at the boundary — the abstraction is worth having, and you must know what's underneath for the moments it shows through. That's why this chapter taught prototypes first.

Descriptors and getters are the metaprogramming floor. Frameworks live one level below your code: Vue 2 rewrote your properties as getter/setter pairs; ORMs define lazy accessor columns; Object.freeze guards config; libraries hide internals with enumerable: false. When a property "behaves oddly" — appears in the debugger but not in Object.keys, throws on assignment, changes value on each read — you're looking at descriptors or accessors, and Object.getOwnPropertyDescriptor is the X-ray. (The next floor down is Proxy, which intercepts all operations on an object — Vue 3's choice; it appears again with V8's optimization story in 3.6.9.)

Shared-once methods are a performance stance. One function object on the prototype versus N copies on N instances is the memory argument (3.6.2 section 7 weighed it against closures); it's also the speed argument — V8's hidden classes and inline caches (3.6.9) love thousands of instances with identical shape sharing one method, and per-instance function properties defeat exactly those optimizations. The rule of thumb stands: data on instances, behavior on prototypes, closures at the edges.

Next: how code is split across files3.6.5: the global-scope catastrophe, IIFE → revealing module → ESM, CommonJS vs ES Modules, and the mystery of the undefined import.

Recall

  • Every property has descriptor flags — writable/enumerable/configurable — set via Object.defineProperty; class methods are non-enumerable, Object.freeze flips flags shallowly. Accessor properties (get/set) run code on read/write — the interception hook behind reactivity systems.
  • Every object links to a prototype; lookup walks the prototype chain to null; reads walk the chain, writes always land on the object (shadowing). Object.create(proto) is the pure form; methods live once on the prototype — prototypal inheritance.
  • Constructor functions + .prototype were the classic idiom: data on instances, behavior shared. class is syntactic sugar over exactly that — plus fields, real private fields (#x, language-enforced), static members on the constructor, and extends/super wiring both prototype chains.
  • instanceof = "is Fn.prototype in x's chain?" — hence it respects extends, breaks across realms (Array.isArray exists for this), can't see interfaces/shapes, and is customizable via Symbol.hasInstance.
  • Expert defaults: prototypes for shared behavior (memory + V8-friendly), composition over deep extends, descriptors as the metaprogramming X-ray.

Self-test: Name the three descriptor flags and one real use of each. Why does assigning never use the prototype chain? What exactly does extends wire — both links? What makes #x stronger than TypeScript's private? State the one-line definition of instanceof and derive why it fails across iframes.

Quiz Bank

FoundationalWhat is the prototype chain, and how does property lookup use it?

Every JavaScript object has a hidden internal link to another object — its prototype. On property access, the engine checks the object's own properties; if absent, it follows the link and checks there, and onward up the prototype chain until the property is found or the chain ends at null (yielding undefined). This is prototypal inheritance: objects inherit directly from other objects. Benefits: shared methods exist once on the prototype (all arrays share one map), and since the chain is consulted at lookup time, adding to a prototype instantly equips every linked object. Key asymmetry: only reads walk the chain — assignment always creates/updates an own property, shadowing any inherited one.

FoundationalIs JavaScript's class real class-based inheritance?

No — syntactic sugar over prototypes. class Dog { bark() {} } still creates a function Dog whose bark lives (non-enumerable) on Dog.prototype; new Dog() still yields an object linked to it; extends wires Puppy.prototype → Dog.prototype (and Puppy → Dog for statics); super.m() calls the parent's method with the current this. No new object model was added — familiar syntax was layered over the prototypal one. Practical consequences: this in methods still binds dynamically (3.6.3) so extracted methods lose it; debuggers still show prototypes; per-instance method copies still waste the sharing. What class did genuinely add: private fields #x (language-enforced), clean static syntax, and mandatory new.

AppliedWhat are property descriptors, and where do they show up in real code?

Every data property carries three flags beside value: writable (may it be assigned), enumerable (does it appear in for…in/Object.keys/spread/JSON), configurable (may it be deleted or re-flagged) — inspect with Object.getOwnPropertyDescriptor, set with Object.defineProperty. Real sightings: class/built-in methods are non-enumerable (why looping an instance doesn't list bark or toString); Object.freeze sets everything non-writable/non-configurable (shallow runtime immutability — the runtime counterpart of TypeScript's compile-time Readonly); libraries hide internal props with enumerable: false; and accessor properties (getter/setter) replace the stored value with code run on read/write — the interception mechanism behind Vue 2 reactivity, lazy computed properties, and validation-on-assignment.

InterviewHow does instanceof work internally, and when does it give wrong answers?

x instanceof Fn walks x's prototype chain asking whether Fn.prototype appears anywhere in it — that's the whole algorithm (overridable via static [Symbol.hasInstance]). It therefore respects inheritance (puppy instanceof Dog → true through the chained prototypes). Wrong/limited answers: (1) cross-realm — an array from an iframe or Node vm context was built against a different Array.prototype object, so instanceof Array is false; Array.isArray exists precisely to fix this; (2) shapes/interfaces — it checks chain membership, not structure, so it cannot validate "has these properties" (TypeScript interfaces are erased and un-instanceof-able — schema validation is the tool there); (3) primitives"hi" instanceof String is false (primitive, not wrapper object; use typeof). How does reflection work? instanceof internals. [EQ-202]

InterviewCompare the three privacy mechanisms: closures, #private fields, and TypeScript private.

Closures (3.6.2): variables captured in a factory's scope — truly inaccessible, works everywhere, but each instance carries its own function copies (memory) and the "class" isn't introspectable. # private fields: language-enforced at runtime — accessing obj.#x outside the class body is a syntax error, invisible to Object.keys/JSON/in; methods still shared on the prototype, so no per-instance cost; brand-checkable with #x in obj inside the class. TypeScript private: a compile-time-only annotation — erased entirely (3.7.1), so at runtime the property is a perfectly ordinary, visible, writable key; it documents intent and stops accidental use in typed code but protects nothing at runtime. Order of strength at runtime: closure ≈ # field > TypeScript private (zero). Choose # for class-based code needing real privacy, closures for factory/functional style, TS private as team convention within a typed codebase.

StaffA memory profile shows an app with 50,000 model instances spending far more RAM than expected, and V8 profiling shows megamorphic property access. The model was written with per-instance arrow methods and dynamic property addition. Explain both findings and the refactor.

Two prototype-model violations, each mapping to a finding. RAM: methods written as instance fields (handle = () => … or assigned in the constructor) allocate one function object per method per instance — 50k instances × k methods = 50k·k closures, versus one shared function on the prototype (3.6.2 section 7 trade-off taken at scale, in the wrong direction — auto-binding is a per-handler convenience, not a modeling default).

Megamorphic access: V8 groups objects by shape (hidden classes, 3.6.9); adding properties dynamically, in varying order, or per-branch gives instances many different shapes, so property-access sites see many shapes and fall off the inline-cache fast path (mono→poly→megamorphic).

Refactor: (1) move behavior to prototype methods via class method syntax — data on instances, behavior shared; bind at the edges only where a callback actually escapes; (2) initialize every field in the constructor, same order, unconditionally (use null/default rather than conditional addition) so all instances share one hidden class; (3) avoid delete (shape transition — set null instead) and avoid mutating prototypes after startup. Expected result: methods collapse to k shared functions, instance memory drops to data-only, and access sites return to monomorphic inline-cache hits. The principle: the prototype model is also V8's optimization contract — data on instances with stable shape, behavior on prototypes.

Flashcards

FlashDescriptor flags

writable (assignable), enumerable (visible to keys/loops/JSON), configurable (deletable/re-flaggable). defineProperty sets; freeze/seal flip in bulk; class methods are non-enumerable.

FlashPrototype chain

Each object links to a prototype; reads walk up until found or null; writes always land on the object (shadowing). Object.create(proto) = purest form.

FlashWhat class really is

Sugar over constructor + prototype: methods → prototype (shared, non-enumerable), fields → own per instance, statics → on the constructor, extends/super → chain wiring.

Flash#private vs TS private

#x: runtime-enforced, syntax error outside, invisible to keys/JSON. TS private: erased at compile time — ordinary property at runtime.

Flashinstanceof in one line

Is Fn.prototype anywhere in x's prototype chain? Breaks cross-realm (use Array.isArray); can't check shapes; Symbol.hasInstance customizes.

FlashGetter/setter

Accessor property: code runs on read/write while looking like data — validation, laziness, reactivity (Vue 2). X-ray: getOwnPropertyDescriptor.

Scenario Drill

DrillA junior teammate serialized a class instance with JSON.stringify and half the object vanished: no methods, no #balance, and a computed total property came out as a plain number that no longer updates. They also report obj.total = 500 silently does nothing. Explain every symptom from this page's machinery and design the fix.

Each symptom is a property-model fact. Methods missing: class methods live on the prototype, and JSON.stringify serializes own, enumerable properties only — it never walks the chain, and methods are non-enumerable besides; functions aren't JSON values anyway.

#balance missing: private fields are invisible outside the class by design — not own enumerable properties, unreachable by any reflection — so no serializer can see them; that's the feature working. total frozen as a number: total is a getterstringify calls it and records the returned snapshot; JSON has no notion of computed properties, so the output is data as-of-serialization (correct behavior, wrong expectation).

obj.total = 500 doing nothing: the object has a getter but no setter; assignment to a getter-only accessor fails silently in sloppy mode (throws under strict/classes) — nothing to do with the prototype chain, everything to do with accessor descriptors.

Fix — make serialization an explicit contract instead of an accident: give the class a toJSON() method (which JSON.stringify automatically honors) returning exactly the intended wire shape — chosen data fields, exposed derivations, private state either omitted or deliberately included; add a matching static fromJSON(obj) that reconstructs a real instance (new, re-linking the prototype so methods return — plain JSON.parse output is a method-less plain object, the reverse symptom). For the assignment confusion, either add a setter with validation or leave it getter-only and document immutability — but under class semantics the failed write throws, which is the loud behavior you want. Team lesson: an instance is prototype-linked behavior + own data + hidden privates; JSON is only the middle slice, so crossing the boundary needs explicit toJSON/fromJSON mapping — the same validate-at-the-edges discipline as 3.7.7.