"JavaScript classes are syntactic sugar over prototypes." I have heard that sentence in a hundred articles, repeated it myself, and watched it confuse new engineers more than it helps. The phrase is technically accurate, but it papers over the parts that matter. The class syntax adds real semantics on top of prototypes (private fields, the super chain, strict-mode bodies, the no-call-without-new rule for the constructor) that you cannot replicate with raw prototype manipulation without writing a lot of careful code. So while the underlying machinery is prototypes, the abstraction the syntax buys is more than sugar.
The argument I want to make in this piece is the contrarian one: if you want to understand JavaScript objects, learn the prototype chain first and the class keyword second. Most of the bugs I have debugged in idiomatic class-using codebases (instanceof returning false unexpectedly, methods missing on subclass instances, Object.assign overwriting the wrong layer) reduce to "the developer thought of classes the way Java thinks of classes, and forgot that under the hood, every class is just a function with a prototype property". The mental model from prototypes is the one that explains the surprises.
What an object actually is
In JavaScript, an object is a bag of own properties plus a hidden link, called the prototype, to another object. When you read a property, the engine checks the object's own properties first; if it does not find one with the matching key, it follows the prototype link and checks that object; and so on, until either the property is found or the chain ends at null.
Object.create(animal) is the most direct way to set the prototype: it creates a new object whose prototype is animal. The chain has two links: dog -> animal -> Object.prototype -> null.
The chain is not a copy. animal is a real object, and dog holds a reference to it. Mutate animal.breathes = false and dog.breathes becomes false next time it is read. This is the same captured-reference-not-snapshot behavior that closures have; it is not specific to prototypes, just consistent across the language.
The two prototypes that confuse everyone
Every object has a prototype. Every function also has a property called prototype. These are two different things, named to maximise confusion.
The hidden prototype of an object is what the engine walks when looking up properties. You access it with Object.getPrototypeOf(obj) (or the legacy obj.__proto__).
A function's prototype property is a regular object that becomes the hidden prototype of any instance created with new Fn(). Until you call new, the function's prototype is just an object sitting on the function, doing nothing.
The new Cat('Whiskers') call did three things: created a fresh empty object, set its hidden prototype to Cat.prototype, and ran Cat with this bound to that object. After the call, c has its own name property and inherits meow from Cat.prototype.
Once you see this clearly, the surprises stop being surprising. Adding a method to Cat.prototype after c was created still makes the method available on c, because the chain is a live reference. Replacing Cat.prototype entirely after c was created does not affect c, because c's hidden prototype was set at construction and is not re-derived.
What class adds beyond function
Classes are not just renamed functions with renamed prototype manipulations. The class keyword introduces five things that pure-prototype code cannot replicate without contortion:
| Feature | Class | Pure prototype |
|---|---|---|
| Body always strict mode | yes | only if file is strict |
Cannot call constructor without new | yes (TypeError) | bare call works, this becomes undefined/global |
| Methods are non-enumerable | yes (default) | enumerable unless you explicitly set Object.defineProperty |
super keyword | yes | ad-hoc with Object.getPrototypeOf |
Private fields (#x) | yes | impossible without WeakMap workarounds |
The fifth row is the one that turned me from "classes are sugar" to "classes are sugar plus real semantics". Private fields use # syntax and are enforced by the engine. They are not accessible by reflection, not iterable, not enumerable, and not present on the prototype; they live per-instance with engine-level isolation. Replicating the same isolation with raw prototypes requires a per-class WeakMap keyed by instances, which is a real chunk of code with its own performance characteristics.
The "syntactic sugar" framing implies that anything class can do, a careful function-and-prototype dance can do too. It is no longer true; private fields are a real new capability.
Inheritance, the chain, and super
Subclassing with extends sets up two chains, not one. The instance chain (instance -> Subclass.prototype -> Superclass.prototype -> Object.prototype) handles method lookup. The class chain (Subclass -> Superclass -> Function.prototype) handles super calls in static methods and the relationship between static members.
super.describe() walks one step up the prototype chain from the call site's home object and invokes the method there. The home object is fixed at definition time; you cannot trick super by reassigning the prototype later. This single guarantee is why super is reliable in a way the old Parent.prototype.method.call(this) pattern was not: that pattern broke if anything reshuffled the chain, while super is bound to the lexical home of the method at compile time.
Where the prototype model leaks
Three places I have watched the prototype underpinning surprise people, even in code that uses class exclusively.
instanceof is a chain check. x instanceof Cat walks x's prototype chain looking for Cat.prototype. If Cat.prototype has been replaced since x was constructed, instanceof returns false. If x came from a different realm (an iframe, a Worker), it has a different Cat.prototype even with the same source code, and instanceof returns false. The fix is duck typing or a Symbol marker, not more instanceof.
Object.assign and spread skip the prototype. Both copy own enumerable properties only. Methods defined on Cat.prototype are not own properties; they are inherited. Spreading a class instance gives you the instance's own properties (the field initializers from the constructor body) and nothing from the prototype.
The fix is to not treat class instances like plain objects. If you need a serialisable copy, define a toJSON or serialize method that returns a plain object explicitly.
Adding properties to Object.prototype is a footgun in the front yard. Every object inherits from Object.prototype. If you assign Object.prototype.toJSON = ..., every object in the runtime has a toJSON method, including library internals you never touched. Modern engines and polyfill authors know better, but I have debugged libraries that broke because some other library modified Array.prototype in a way that interfered with iteration.
Where I recommend reaching for which
The pattern that has held up in production for me is straightforward.
For domain types in application code (User, Order, Comment), use class. The strict-mode body, the new-only constructor, the private fields, and the readability win pay back the small cost of "remembering classes are prototypes underneath".
For utility objects with no methods (config, options, plain data), use object literals or Object.create(null) (the null variant skips Object.prototype, which is sometimes useful for dictionary objects whose keys might collide with toString or hasOwnProperty).
For factory patterns where the constructor needs to return something other than the freshly-constructed object, use plain functions. Classes can technically do this (return an object from a constructor and new returns it instead of this), but the syntax fights the intent.
For modifying built-ins, almost never. The handful of cases where it pays (polyfills for not-yet-standard methods, rare cross-cutting concerns under your full control) are vastly outnumbered by the cases where it bites someone three years later.
Classes are a real abstraction, not a translation layer
The "syntactic sugar" framing was useful in 2015, when class was new and engineers needed reassurance that nothing weird was happening underneath. In 2026, with private fields, the strict semantics, and the integrated super model, class is an abstraction with its own surface area, built on top of prototypes but not equivalent to them. Knowing the prototype chain explains the surprises (instanceof quirks, Object.assign skipping methods, prototype mutation visible across instances), but pretending classes are merely a different way to spell the same prototype manipulations is a 2015 mental model that a modern codebase will trip up.
Use classes for domain types, learn prototypes well enough to debug the surprises, and stop worrying about "is this real OOP". Both patterns are tools; both are part of the language; the one that fits the problem is the right one for that file.
