JavaScript Prototypes and Classes: What class Syntax Actually Does
JavaScriptโs class syntax looks reassuringly familiar to anyone from Java or C# โ and that familiarity is a well-designed veneer. Underneath, JS has no classes in the classical sense. It has objects linked to other objects, with a lookup rule. Thatโs the entire inheritance system: the prototype chain.
You can write productive JS using only class syntax and never look underneath. But the seams show constantly โ in debugging (__proto__ in every console output), in interviews (a top-five question), in library code, and in error messages. This guide teaches the real model first, then shows how class maps onto it โ after which both layers are simple.
The Core Mechanism: Delegated Property Lookup
Every JavaScript object has a hidden internal link to another object โ its prototype (or to null, ending the chain). Property lookup follows one rule:
Read
obj.x: ifobjhas its own propertyx, use it. Otherwise, look atobjโs prototype. Repeat up the chain until found or the chain ends (undefined).
const baseConfig = { retries: 3, timeout: 5000 };const jobConfig = Object.create(baseConfig); // creates {} with prototype = baseConfigjobConfig.timeout = 30_000; // own property, shadows the prototype's
jobConfig.timeout // 30000 โ found on jobConfig itselfjobConfig.retries // 3 โ not own; found one link up, on baseConfigjobConfig.missing // undefined โ chain exhausted
Object.getPrototypeOf(jobConfig) === baseConfig // truejobConfig.hasOwnProperty("retries") // false โ it's inherited, not ownNotice what this is: not copying, not classical inheritance โ live delegation. jobConfig doesnโt contain retries; it defers to an object that does. Change baseConfig.retries and every object delegating to it sees the new value instantly. And writing jobConfig.retries = 5 doesnโt touch baseConfig โ assignment creates an own property that shadows the inherited one (a frequent source of โinheritanceโ confusion: reads climb the chain; writes donโt).
This rule already explains things youโve used all along:
const arr = [1, 2, 3];arr.map(x => x * 2); // arr has no 'map' of its own...// arr โ Array.prototype (map, filter, push live HERE, once, shared) โ Object.prototype โ null
"abc".toUpperCase(); // same story via String.prototype({}).toString(); // Object.prototype.toString โ the chain's last stopEvery array shares one map function, living on Array.prototype. A million arrays, one method โ thatโs the memory model prototypes exist for. Itโs also why โmonkey-patchingโ (Array.prototype.myHelper = ...) affects every array in the program โ powerful, and banned by most style guides for good reason.
What new Actually Does
Before class, constructors were plain functions used with new. Understanding new is understanding the whole system โ it does four things:
function Account(owner) { // just a function this.owner = owner; // (2) this = the new object this.paise = 0;}Account.prototype.deposit = function (p) { // methods go on the prototype, shared this.paise += p;};
const a = new Account("Priya");new Account("Priya"):
- Creates a fresh empty object.
- Sets its prototype link to
Account.prototype. - Runs the function body with
thisbound to the new object (Rule 4). - Returns the object (unless the body explicitly returns another object โ a quirk, not a tool).
So a gets own data (owner, paise) and delegated behavior (deposit, found via the chain):
a { owner: "Priya", paise: 0 } โถ Account.prototype { deposit } โถ Object.prototype { toString, hasOwnProperty, ... } โถ nullinstanceof is a chain question: a instanceof Account asks โdoes Account.prototype appear anywhere in aโs chain?โ โ nothing more mystical than that.
class: The Same Machine, Humane Syntax
ES2015โs class is (almost entirely) syntax over the constructor-function pattern โ deliberate, standardized sugar:
class Account { #paise = 0; // private field (ES2022) โ truly inaccessible outside
constructor(owner) { this.owner = owner; }
deposit(p) { // โ goes on Account.prototype, shared if (p <= 0) throw new Error("deposit must be positive"); this.#paise += p; }
get balance() { // getter โ read as a.balance, no parens return this.#paise; }
static fromJSON(json) { // โ on Account itself, not instances const acc = new Account(json.owner); acc.deposit(json.paise); return acc; }}
const a = new Account("Priya");a.deposit(50_000);a.balance; // 50000a.#paise; // SyntaxError โ privacy enforced by the languageMapping to the machine: deposit lands on Account.prototype exactly as the manual version did; static members land on the constructor function itself (compare Javaโs static); new performs the same four steps. What class adds beyond sugar: required new (calling without it throws instead of corrupting globals), always-strict bodies, #private fields with real enforcement (closing the gap closures used to fill alone), and saner inheritance syntax:
class SavingsAccount extends Account { constructor(owner, rate) { super(owner); // parent constructor first โ same rule as Java this.rate = rate; } addInterest() { this.deposit(Math.round(this.balance * this.rate)); // inherited API }}extends wires two chains: instances go savings โ SavingsAccount.prototype โ Account.prototype โ Object.prototype, and method lookup walks it โ which is all โmethod overridingโ is in JS: a nearer property shadowing a farther one. super.method() continues the lookup one level up, explicitly.
Guidance that reflects the modern ecosystem: write class when you want class-shaped things โ many instances, shared methods, real instanceof identity (errors, domain entities, framework integration). Donโt reach for it reflexively โ plain objects, factory functions with closures, and modules cover a great deal of what other languages need classes for, and much of the JS world (React included) has drifted toward functions. Both are idiomatic; class-hierarchies-for-everything is not. And the composition-over-inheritance caution from the Java series applies with extra force in JS: keep extends chains shallow (one level, usually), or flatten them into composition.
Prototype Literacy for Debugging and Interviews
The bits that separate โuses classesโ from โunderstands the systemโ:
// The three prototype accessorsObject.getPrototypeOf(a) // the real API for reading the linka.__proto__ // legacy accessor โ fine in DevTools, avoid in codeAccount.prototype // property of the CONSTRUCTOR: "what instances will link to"// __proto__ (instance's link) vs .prototype (constructor's template) โ the naming// collision behind 90% of prototype confusion. They meet only via new.
// Own vs inherited iterationObject.keys(a) // own enumerable only: ["owner"]for (const k in a) {} // own AND inherited enumerable โ usually not what you wantObject.hasOwn(a, "deposit") // false โ modern replacement for hasOwnProperty
// Objects without any prototypeconst dict = Object.create(null); // no toString, no hasOwnProperty โ a pure dictionaryThat last one explains a pattern youโll meet in libraries: Object.create(null) makes collision-proof dictionaries (no inherited constructor key lurking). Though in modern code, Map is usually the better dictionary anyway.
Console literacy: when DevTools shows an object, the expandable [[Prototype]] (or __proto__) entry is the chain โ walk it and youโre reading the objectโs ancestry directly. Seeing a method there, not on the object itself, is normal and correct.
Worked Pattern: A Custom Error Family
The one inheritance hierarchy nearly every codebase should have โ and a compact tour of this chapterโs machinery in production shape:
class AppError extends Error { constructor(message, options) { super(message, options); // Error supports { cause } โ keep the chain this.name = this.constructor.name; // subclasses get their own name automatically }}
class NotFoundError extends AppError { constructor(resource, id) { super(`${resource} ${id} not found`); this.resource = resource; this.id = id; }}
class ValidationError extends AppError { constructor(issues) { super(`Validation failed: ${issues.length} issue(s)`); this.issues = issues; }}
// throwing and routing:throw new NotFoundError("Order", 42);
try { await save(order); }catch (e) { if (e instanceof ValidationError) return showFieldErrors(e.issues); if (e instanceof NotFoundError) return show404(); throw e; // bugs keep propagating โ never swallow}Everything from this guide is load-bearing here: extends Error wires the prototype chain so instanceof routing works; super(message) runs the parent constructor; this.constructor.name reads through the chain to the actual subclass; own properties (issues, resource) carry structured data that string matching never could. The error-design principles from the Java series โ small family, structured data, narrow catches, preserve causes โ translate wholesale; only the syntax changed. If you build one class hierarchy this year, make it this one.
Frequently Asked Questions
So does JavaScript have โrealโ classes now or not? The syntax is real and standard; the semantics remain prototypal delegation. The distinction matters rarely โ but it matters: prototypes are live (modifying Account.prototype.deposit changes behavior of existing instances โ try that in Java), lookup is dynamic, and objects can be linked without any constructor at all. โSugarโ undersells class; โJava classesโ oversells it.
When do I actually need Object.create or manual prototypes? Almost never in application code โ class and object literals cover it. The manual layer surfaces in library internals, polyfills, certain performance-sensitive patterns, and interviews. The reading knowledge is what you need.
Whatโs the constructor property? a.constructor walks the chain to Account.prototype.constructor, which points back at Account. Occasionally useful (a.constructor.name for logging), historically fragile (itโs just a writable property). Prefer instanceof for identity checks.
How do private fields (#) differ from the closure pattern and TypeScriptโs private? #field is enforced by the engine โ invisible outside, full stop, and even subclasses canโt see it. Closure privacy is equally airtight, function-shaped instead. TypeScriptโs private keyword is compile-time advice โ erased at runtime, bypassable with bracket access. For real runtime privacy: # or closures.
Why does extends from built-ins like Array sometimes behave oddly? Subclassing built-ins (Array, Error) works in modern engines but carries edge cases (method return types, transpilation quirks with older targets). Extending Error for custom error types is the one broadly recommended case โ as in Java, a small family of domain errors pays off; deep built-in subclassing generally doesnโt.
Mixins: Multiple Inheritanceโs JS Workaround
Since JS classes extend only one parent, shared cross-cutting behavior (serialization, event emitting, timestamps) uses mixins โ functions that take a class and return an extended one:
const Serializable = (Base) => class extends Base { toJSON() { return { ...this }; }};const Timestamped = (Base) => class extends Base { createdAt = new Date();};
class Order extends Serializable(Timestamped(Object)) { /* ... */ }Each mixin inserts a link into the prototype chain โ Order โ Serializable's class โ Timestamped's class โ Object โ so lookup finds every layerโs methods. Itโs a clever pattern worth reading fluently (several libraries use it), and worth writing rarely: plain composition (a serializer utility taking any object, a closure-based helper) usually delivers the same reuse with less chain magic. The deeper takeaway is the mechanism: because classes are values and extends accepts any expression, JS can build inheritance hierarchies dynamically โ something classical languages canโt โ which is prototypes showing through the class syntax one more time.
Self-Check
class A { greet() { return "a"; } }class B extends A { greet() { return "b" + super.greet(); } }const b = new B();
// 1b.greet();// 2Object.hasOwn(b, "greet");// 3b instanceof A;// 4 โ what does this do to EXISTING instances?A.prototype.greet = function () { return "z"; };b.greet();// 5const plain = { x: 1 };Object.getPrototypeOf(plain) === Object.prototype;Answers: (1) "ba" โ Bโs method shadows, super continues lookup upward. (2) false โ methods live on B.prototype, not the instance. (3) true โ A.prototype is in bโs chain. (4) "bz" โ prototypes are live; super.greet() now finds the replaced function. If that surprised you, reread delegation โ nothing was copied anywhere. (5) true โ literals link to Object.prototype.
Next: the chapter that explains why your console.log order keeps surprising you โ The Event Loop, JavaScriptโs concurrency model and the foundation under every promise and callback in the language.