Skip to content

Volume ITechnology

3.6.4 — Objects, Prototypes & Classes ​

3.6.1–3.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

You meet this machinery every day in two places. The first is why class methods do not show up when you loop over an instance: they are defined on the prototype as not enumerable. The second is the object-locking functions:

  • Object.freeze(obj): no property can be written or reconfigured, and none can be added. It locks only the top level, at runtime.
  • Object.seal: no adding or deleting properties, but values can still be changed.
  • Object.preventExtensions: only adding properties is blocked.

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). Knowing this pays off in three ways:

  • The debugger and error messages still talk in terms of prototypes.
  • Your sense of performance carries over. Adding methods to each instance throws away the sharing.
  • The advice of 3.5, composition over inheritance, applies even more strongly. The object model combines objects naturally, and long extends chains carry all the usual costs of inheritance.

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

From that one rule, you can work out the rest:

  • It works through extends. A Puppy is a Dog, because the chain passes through both prototypes.
  • It fails across separate JavaScript worlds. An array made inside an iframe or a Node vm has a different Array.prototype, so instanceof Array is false. That is why Array.isArray exists.
  • It says nothing about shape. That is why you cannot check a TypeScript interface with instanceof (3.7.7).
  • You can change it. A class can define static [Symbol.hasInstance](x) (3.6.6) and 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 files — 3.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 next to its value:

  • writable: can it be assigned a new value?
  • enumerable: does it show up in for…in, Object.keys, spread and JSON?
  • configurable: can it be deleted, or can its flags be changed?

You read them with Object.getOwnPropertyDescriptor and set them with Object.defineProperty. Where you meet them in real code:

  • Methods of classes and built-ins are not enumerable. That is why looping over an instance does not list bark or toString.
  • Object.freeze makes every property not writable and not configurable. It only freezes the top level, at runtime. It is the runtime version of TypeScript's compile-time Readonly.
  • Libraries hide their internal properties with enumerable: false.
  • Accessor properties, meaning getters and setters, replace the stored value with code that runs when you read or write. This is how Vue 2 noticed changes, how values get computed only when first needed, and how assignments get checked.
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). Where it gives a wrong or useless answer:

  1. Separate JavaScript worlds. An array from an iframe or a Node vm context was built from a different Array.prototype object, so instanceof Array is false. Array.isArray exists to fix exactly this.
  2. Shapes and interfaces. It checks the chain, not the structure, so it cannot answer "does this have these properties?". TypeScript interfaces vanish at runtime, so you cannot instanceof them. Use schema validation for that.
  3. Primitives. "hi" instanceof String is false, because "hi" is a primitive, not a 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. Memory. Methods written as instance fields, such as handle = () => … or functions assigned in the constructor, create a new function object for every method on every instance. With 50,000 instances and k methods, that is 50,000 × k functions, instead of one shared function per method on the prototype. That is the trade-off from 3.6.2 section 7, taken at scale in the wrong direction. Binding a function automatically is a convenience for a few event handlers, not a default way to write methods.

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 behaviour into class methods, so it lives on the prototype. Data stays on each instance, and the methods are shared. Only bind a method at the edges, where it is actually passed away as a callback.
  2. Set every field in the constructor, always in the same order, and always, not only sometimes. Use null or a default instead of adding the field later. Then all instances share one hidden class.
  3. Do not use delete, because it changes the object's shape. Set the field to null instead. And do not change prototypes after startup.

The result: the methods shrink to a handful of shared functions, each instance holds only its data, and property reads become fast again because each one sees a single shape. 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 getter — stringify 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 turning it into JSON a deliberate contract, not an accident.

  • Give the class a toJSON() method. JSON.stringify calls it automatically. It returns exactly the shape you want to send: the chosen data fields, any computed values you want to expose, and private data only if you decide to include it.
  • Add a matching static fromJSON(obj) that builds a real instance again with new. That links the prototype back up, so the methods return. Plain JSON.parse output is a plain object with no methods, which is the same problem in reverse.
  • For the confusing assignment, either add a setter that checks the value, or leave it as a getter only and document that it cannot be changed. Inside a class, a failed write throws an error, and that loud failure is what 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.