Prototype vs Class: When to Use Each
Class syntax sits on top of prototypes. Knowing both helps you debug and design APIs.
What Prototype Actually Is
Every object has an internal [[Prototype]] slot pointing at another object (or null). Property lookup walks the chain: check the object's own properties, then its prototype, then the prototype's prototype, until a match or null.
- const animal = { eats: true };
- const dog = Object.create(animal);
- dog.barks = true;
- dog.eats — true, found one link up the chain
Do not confuse the two "prototype" names: Dog.prototype is a regular property that only functions have, holding the object that instances created via new Dog() will get as their [[Prototype]]. The instance's actual link is readable with Object.getPrototypeOf(dog). The legacy __proto__ accessor works but is deprecated for everything except reading in a console.
Writes never walk the chain: assigning dog.eats = false creates an own property that shadows the inherited one; the prototype is untouched. This asymmetry — reads delegate, writes shadow — explains most "spooky action" bugs with shared mutable state on prototypes.
Class Syntax, Desugared
- class Dog { constructor(name) { this.name = name; } bark() { return 'woof'; } }
is nearly equivalent to:
- function Dog(name) { this.name = name; }
- Dog.prototype.bark = function () { return 'woof'; };
extends wires two chains at once: Child.prototype links to Parent.prototype (for instance methods) and Child itself links to Parent (so static members inherit too). super.method() works via an internal [[HomeObject]] reference stored on the method — which is why you cannot extract a method that uses super, attach it to a different object, and expect it to work.
Differences That Bite
The sugar is not zero-calorie. Real behavioral differences:
- Hoisting — function declarations hoist fully; class declarations sit in the temporal dead zone and throw if touched early
- Strict mode — class bodies are always strict, even in sloppy files
- Construction guard — calling a class without new throws a TypeError; a plain constructor function silently runs with this bound to globalThis, corrupting it
- Enumerability — class methods are non-enumerable; manual Dog.prototype.bark = ... assignments are enumerable and show up in naive object copies
- Class fields are own properties. count = 0 in a class body runs per instance in the constructor, not on the prototype. An arrow-function field like handleClick = () => {...} allocates a new function per instance — convenient for this binding, wasteful at scale, and invisible to Child.prototype overrides expecting a prototype method
Private State: #fields vs Closures
#name fields give engine-enforced privacy with prototype-shared methods, but they are brand-checked per class — proxies and some serialization patterns fight them. Closure-based factories give privacy without this at the cost of per-instance method allocations. Both are legitimate; pick per hot-path requirements, not ideology.
Performance Reality
Engines optimize objects by hidden shape: objects created the same way share a shape, and monomorphic property access compiles to a single offset load. Classes naturally produce uniform shapes, which is why they benchmark well for large populations of similar objects. What actually hurts:
- Adding properties in different orders or conditionally, forking shapes
- Mutating [[Prototype]] after creation (Object.setPrototypeOf) — deoptimizes hard; wire chains at construction
- Very long prototype chains — each lookup miss walks them; keep hierarchies shallow
When to Use What
- Plain objects — data with no behavior: config, API payloads, state snapshots. Trivial to serialize, spread, and destructure
- Classes — many instances sharing methods, instanceof checks, frameworks that expect them (Web Components must extend HTMLElement), performance-sensitive object populations
- Closure factories — module-scale singletons, utilities needing real privacy, code where this-binding bugs have burned the team
- Object.create / raw prototypes — niche: exotic delegation patterns, null-prototype dictionaries via Object.create(null) that dodge prototype-pollution and hasOwnProperty collisions
Prototype pollution deserves its mention: merging untrusted JSON into objects can smuggle a __proto__ key and poison Object.prototype for the whole realm. Null-prototype objects or Map for dynamic keys, plus merge libraries that block proto keys, are the defense.
Debugging the Chain
Three tools answer almost every prototype question. Object.getPrototypeOf(obj) shows the actual link. obj.hasOwnProperty(key) — or the modern Object.hasOwn(obj, key) — distinguishes own properties from inherited ones, which matters because in and for...in see the whole chain while Object.keys sees only own enumerable keys. And in DevTools, expanding an object's internal prototype entry lets you walk the chain visually; a method appearing two levels deep instead of one usually explains an override that never fires.
The Modern Compromise
For most application code, closure-based factories and plain objects cover 90% of needs with the least ceremony. Reserve classes for genuinely object-oriented domains — components, entities, resource handles — and understand the prototype chain regardless, because that is what the debugger, instanceof, spread behavior, and every framework's magic are actually built on.