Prototypes and classes
JavaScript doesn’t have classes (sort of)
Section titled “JavaScript doesn’t have classes (sort of)”JavaScript has the class keyword. It looks like classes. It quacks like classes. But under the hood, it’s prototypes all the way down. ES6 classes are syntactic sugar over JavaScript’s prototype-based inheritance system. Understanding what’s underneath matters because the abstraction leaks in specific situations.
The prototype chain
Section titled “The prototype chain”I ignored prototypes for my first year of writing JavaScript — classes worked, why look underneath? Then I needed to add a method to every array in a project, tried extending Array.prototype, broke a third-party library that was iterating with for...in, and suddenly needed to understand exactly how property lookup works.
Every JavaScript object has a hidden link to another object called its prototype. When you access a property on an object and the object doesn’t have it, JavaScript follows the prototype chain to look for it.
const animal = { speak() { return `${this.name} makes a sound`; },};
const dog = Object.create(animal);dog.name = "Rex";
dog.speak(); // "Rex makes a sound"dog.hasOwnProperty("name"); // true (own property)dog.hasOwnProperty("speak"); // false (inherited from prototype)Object.create(animal) creates a new object whose prototype is animal. When dog.speak() is called, JavaScript first checks dog for a speak property. It doesn’t find one. It follows the prototype link to animal, finds speak there, and calls it.
The chain continues. animal’s prototype is Object.prototype (which has hasOwnProperty, toString, etc.). Object.prototype’s prototype is null (the end of the chain).
dog → animal → Object.prototype → null name: "Rex" speak() hasOwnProperty(), toString()This is how inheritance works in JavaScript. It’s not “a Dog IS-A Animal” (class inheritance). It’s “this object delegates to that object” (prototypal delegation).
Try it: walk the prototype chain
Section titled “Try it: walk the prototype chain”Walk the prototype chain
Click a property to see where JavaScript finds it. Or use step-through to walk the chain one level at a time.
Constructor functions (the old way)
Section titled “Constructor functions (the old way)”Before class, you used constructor functions and .prototype:
function Dog(name, breed) { this.name = name; this.breed = breed;}
Dog.prototype.speak = function () { return `${this.name} barks`;};
Dog.prototype.fetch = function (item) { return `${this.name} fetches the ${item}`;};
const rex = new Dog("Rex", "Shepherd");rex.speak(); // "Rex barks"The new keyword does four things:
- Creates a new empty object
- Sets the object’s prototype to
Dog.prototype - Calls
Dogwiththispointing to the new object - Returns the new object (unless the constructor explicitly returns something else)
Methods go on Dog.prototype so they’re shared across all instances. If you put them inside the constructor (this.speak = function() {...}), every instance gets its own copy of the function, which wastes memory.
ES6 classes (the new syntax)
Section titled “ES6 classes (the new syntax)”Same thing, cleaner syntax:
class Dog { constructor(name, breed) { this.name = name; this.breed = breed; }
speak() { return `${this.name} barks`; }
fetch(item) { return `${this.name} fetches the ${item}`; }}
const rex = new Dog("Rex", "Shepherd");This compiles to essentially the same prototype chain as the constructor function version. speak and fetch end up on Dog.prototype. name and breed are own properties on each instance.
The class syntax adds a few things the old way didn’t have:
extendsfor inheritance (cleaner than manually wiring prototype chains)superfor calling the parent constructor/methodsstaticfor class-level methods- Private fields (
#name) that are truly inaccessible from outside
Inheritance with extends
Section titled “Inheritance with extends”class Animal { constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound`; }}
class Dog extends Animal { constructor(name, breed) { super(name); // MUST call super before using this this.breed = breed; }
speak() { return `${this.name} barks`; // overrides Animal.speak }
parentSpeak() { return super.speak(); // calls Animal.speak }}
const rex = new Dog("Rex", "Shepherd");rex.speak(); // "Rex barks"rex.parentSpeak(); // "Rex makes a sound"rex instanceof Dog; // truerex instanceof Animal; // trueThe prototype chain: rex → Dog.prototype → Animal.prototype → Object.prototype → null.
instanceof walks up the chain checking if any prototype matches.
Private fields
Section titled “Private fields”ES2022 added truly private fields using the # prefix:
class BankAccount { #balance; // private, inaccessible from outside
constructor(initial) { this.#balance = initial; }
deposit(amount) { if (amount <= 0) throw new Error("Amount must be positive"); this.#balance += amount; }
getBalance() { return this.#balance; }}
const account = new BankAccount(100);account.deposit(50);account.getBalance(); // 150account.#balance; // SyntaxError: Private field '#balance' must be declared in an enclosing classBefore #private, people used underscores (_balance) as a convention. But it was just a convention. Anyone could access account._balance. The # prefix is enforced by the engine.
In TypeScript, private is a compile-time check only. The JavaScript output has no enforcement. If you need runtime privacy, use #.
Static methods
Section titled “Static methods”Methods that belong to the class itself, not instances:
class User { constructor(name, email) { this.name = name; this.email = email; }
// Instance method: called on a user instance greet() { return `Hi, I'm ${this.name}`; }
// Static method: called on the User class static fromJSON(json) { const data = JSON.parse(json); return new User(data.name, data.email); }
static validateEmail(email) { return email.includes("@"); }}
const user = User.fromJSON('{"name":"Alice","email":"a@b.com"}');User.validateEmail("test@example.com"); // trueuser.validateEmail; // undefined (static methods aren't on instances)Factory methods (fromJSON) and utility functions (validateEmail) are common use cases. They don’t need an instance to do their work.
When to use classes in modern JavaScript
Section titled “When to use classes in modern JavaScript”This is a genuine debate. The React community largely moved away from classes toward functions and hooks. The Node community uses classes for some patterns (EventEmitter, Transform streams) but functions for most application code.
Classes work well for:
- Objects with identity and mutable state (a database connection, a WebSocket client)
- Inheritance hierarchies that actually make sense (EventEmitter → TypedEmitter)
- APIs where users create instances (
new Something())
Functions work well for:
- Stateless transformations (most application logic)
- Composition over inheritance (hooks, middleware, pipelines)
- When you need closures for privacy (classes have
#now, but closures are still simpler for simple cases)
In TypeScript, interfaces and type composition largely replace the need for class hierarchies. You define shapes, not class trees.
The this problem
Section titled “The this problem”this in JavaScript depends on HOW a function is called, not where it’s defined. This is the source of more bugs than any other JavaScript feature.
class Timer { constructor() { this.seconds = 0; }
start() { // BUG: this.tick loses 'this' when passed as a callback setInterval(this.tick, 1000); }
tick() { this.seconds++; // class methods are strict: detached calls have undefined `this` console.log(this.seconds); }}Three fixes:
// 1. Arrow function (captures this from enclosing scope)start() { setInterval(() => this.tick(), 1000);}
// 2. Bind in constructorconstructor() { this.seconds = 0; this.tick = this.tick.bind(this);}
// 3. Class field with arrow (most modern)tick = () => { this.seconds++; console.log(this.seconds);}Arrow functions don’t have their own this. They capture this from the scope where they’re defined. This is why React event handlers work with arrows but break with regular methods.
Event delegation
Section titled “Event delegation”Event delegation uses event bubbling to handle events on a parent element instead of attaching listeners to every child.
// Instead of this (one listener per button):document.querySelectorAll(".item").forEach((item) => { item.addEventListener("click", handleClick);});
// Do this (one listener on the parent):document.querySelector(".list").addEventListener("click", (e) => { const item = e.target.closest(".item"); if (item) handleClick(item);});Why delegation is better:
- Performance: One listener vs hundreds. Less memory, fewer setup calls.
- Dynamic elements: New items added to the list automatically work. No need to attach listeners to dynamically created elements.
- Cleanup: Remove one listener instead of tracking hundreds.
This is how React handles events internally. React attaches a single listener to the root of your app and uses delegation for all events.
Interview angles
Section titled “Interview angles”“How does event delegation work?” Event delegation uses bubbling: events on child elements propagate up to parent elements. You attach one listener on the parent and check event.target to determine which child was clicked. It’s more efficient than individual listeners (less memory, works with dynamically added elements). React uses this approach internally.
“Explain prototypal inheritance.” JavaScript objects have a prototype chain. When you access a property, JS walks up the chain until it finds it or hits null. ES6 classes are syntax over this: methods go on ClassName.prototype, extends sets up the chain, new creates instances linked to the prototype. Unlike classical inheritance, there are no copies; instances delegate to their prototype.
“What’s the difference between class and a constructor function?” Syntactic sugar. Both create the same prototype chain. Classes add cleaner syntax, extends/super, static, and #private fields. Under the hood, typeof MyClass is "function".