Classes vs Prototypes
ES6 Classes Are Syntactic Sugar
ES6 class is NOT a new object-oriented model. It's syntactic sugar over JavaScript's prototype-based system. Under the hood, everything still uses prototypes.
javascript// ES6 Class syntax
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound`;
}
static create(name) {
return new Animal(name);
}
}
// EXACTLY equivalent prototype code:
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function() {
return `${this.name} makes a sound`;
};
Animal.create = function(name) {
return new Animal(name);
};Both produce identical behavior. The class syntax is just more readable and provides some additional features.
What class Actually Does
class syntax with extends does two distinct things: it sets up the instance prototype chain so instances of the subclass inherit instance methods from the parent, and it sets up the constructor function chain so the subclass also inherits static methods from the parent. Both are accomplished by manipulating prototypes under the hood, and you can verify this directly by inspecting Dog.prototype.__proto__ and Dog.__proto__ after a class declaration.
javascriptclass Dog extends Animal {
constructor(name, breed) {
super(name); // calls Animal constructor
this.breed = breed;
}
bark() {
return `${this.name} barks!`;
}
}
// After class Dog declaration, here's what exists:
// Dog.prototype = {
// bark: function() { ... },
// constructor: Dog,
// __proto__: Animal.prototype ← extends sets this up
// }
// Dog.__proto__ = Animal ← for static method inheritance
const fido = new Dog('Fido', 'Labrador');
// fido.__proto__ === Dog.prototype (true)
// Dog.prototype.__proto__ === Animal.prototype (true)Chain: fido → Dog.prototype → Animal.prototype → Object.prototype → null
The new Keyword — 4 Steps
Understanding new is critical. When you call new Constructor(args):
- Create a new empty object
{} - Set its
[[Prototype]]toConstructor.prototype - Call
Constructorwiththisbound to the new object - Return the new object (unless constructor explicitly returns an object)
javascript// Manual implementation of new:
function myNew(Constructor, ...args) {
// Step 1: create empty object
const obj = {};
// Step 2: set prototype
Object.setPrototypeOf(obj, Constructor.prototype);
// Step 3: call constructor with this = obj
const result = Constructor.apply(obj, args);
// Step 4: return obj (or result if constructor returned an object)
return (typeof result === 'object' && result !== null) ? result : obj;
}
function Person(name) {
this.name = name;
}
const alice = myNew(Person, 'Alice');
alice.name; // 'Alice'
alice instanceof Person; // trueextends and super — How Inheritance Works
extends wires up the prototype chain so the subclass inherits from the parent. super provides two capabilities: calling the parent class constructor (super(args)) and calling a specific parent class method (super.methodName()). In a derived class constructor, this does not exist until after super() is called — the parent constructor is responsible for allocating and initializing this. Forgetting super() in a subclass constructor throws a ReferenceError: Must call super constructor in derived class before accessing 'this'.
javascriptclass Vehicle {
constructor(make, model) {
this.make = make;
this.model = model;
}
describe() {
return `${this.make} ${this.model}`;
}
}
class Car extends Vehicle {
constructor(make, model, doors) {
super(make, model); // MUST call super before using this
this.doors = doors;
}
describe() {
// super.describe() calls parent's describe method
return `${super.describe()} with ${this.doors} doors`;
}
}
const tesla = new Car('Tesla', 'Model 3', 4);
tesla.describe(); // 'Tesla Model 3 with 4 doors'Why super() must come first: Before super() is called, this doesn't exist in the derived class constructor. The parent constructor is responsible for setting up this.
Class Features
Private Fields (ES2022)
Private class fields (#field) are a language-level privacy mechanism that makes properties completely inaccessible outside the class body — not even through obj['#field'] or DevTools object inspection can bypass them (DevTools shows them in a separate "Private properties" section). They are enforced by the parser and are fundamentally different from the naming convention _private or WeakMap-based approaches. Private fields are stored as part of the instance's internal slot structure, not on the prototype chain, so they have no inheritance and no hasOwnProperty visibility.
javascriptclass BankAccount {
#balance; // truly private — not accessible outside class
#id;
constructor(initialBalance) {
this.#balance = initialBalance;
this.#id = Math.random().toString(36);
}
deposit(amount) {
if (amount <= 0) throw new Error('Positive amounts only');
this.#balance += amount;
return this;
}
get balance() { return this.#balance; }
// Private method
#validateAmount(amount) {
return amount > 0 && amount <= this.#balance;
}
withdraw(amount) {
if (!this.#validateAmount(amount)) throw new Error('Invalid amount');
this.#balance -= amount;
return this;
}
}
const acc = new BankAccount(1000);
acc.deposit(500).withdraw(200);
acc.balance; // 1300
acc.#balance; // SyntaxError — truly private!Static Methods and Properties
Static members belong to the class constructor itself, not to instances. They are not accessible via instance.method() — only via ClassName.method(). Static methods are useful for factory functions, utility helpers, and managing class-level state (like an instance counter). They are inherited by subclasses: SubClass.staticMethod() works because SubClass.__proto__ === ParentClass (another prototype chain, this time for the constructor functions themselves).
javascriptclass MathUtils {
static PI = 3.14159;
static circleArea(r) {
return MathUtils.PI * r * r;
}
static #instances = 0; // private static
constructor() {
MathUtils.#instances++;
}
static getInstanceCount() {
return MathUtils.#instances;
}
}
MathUtils.circleArea(5); // 78.54
MathUtils.PI; // 3.14159
new MathUtils();
MathUtils.getInstanceCount(); // 1Getters and Setters
Getters and setters define computed or validated properties that appear as plain property accesses to the caller. A getter is invoked when you read obj.property; a setter is invoked when you write obj.property = value. They are defined on the prototype (not on instances), so they are shared and incur no per-instance memory overhead. Use them when a property value should be derived from other state, or when assignment needs validation logic, rather than exposing the raw underlying field.
javascriptclass Temperature {
#celsius;
constructor(celsius) {
this.#celsius = celsius;
}
get fahrenheit() {
return this.#celsius * 9/5 + 32;
}
set fahrenheit(f) {
this.#celsius = (f - 32) * 5/9;
}
get celsius() { return this.#celsius; }
set celsius(c) { this.#celsius = c; }
}
const temp = new Temperature(0);
temp.fahrenheit; // 32
temp.fahrenheit = 212; // setter
temp.celsius; // 100Class vs Factory Function — The Debate
Class
javascriptclass UserClass {
#name;
#email;
constructor(name, email) {
this.#name = name;
this.#email = email;
}
greet() { return `Hi, I'm ${this.#name}`; }
}Factory Function
javascriptfunction createUser(name, email) {
// name and email are private via closure
return {
greet() { return `Hi, I'm ${name}`; }
// no prototype — each instance has own copy of methods
};
}| Class | Factory Function | |
|---|---|---|
| Memory | Methods shared via prototype | Each instance has own method copies |
| Private state | #privateFields |
Closure |
instanceof |
Works | Doesn't work |
this binding |
Can be lost | N/A (methods use closure, no this) |
| Inheritance | extends keyword |
Manual composition |
| Use case | Many instances sharing behavior | Flexibility, functional patterns |
Mixins — Multiple Inheritance Workaround
JavaScript's prototype chain is linear — a class can only extend one parent — so true multiple inheritance is not available. Mixins are a composition pattern that works around this by defining behavior as functions that take a superclass and return a new subclass that extends it. By chaining mixin calls, you layer multiple behaviors onto a base class. Each mixin forms a new anonymous class in the prototype chain, which is why deep mixin stacks have a slight prototype lookup overhead.
JavaScript doesn't support multiple inheritance, but you can compose functionality with mixins:
javascript// Mixin functions
const Serializable = (superclass) => class extends superclass {
serialize() {
return JSON.stringify(this);
}
static deserialize(json) {
return Object.assign(new this(), JSON.parse(json));
}
};
const Validatable = (superclass) => class extends superclass {
validate() {
// check required fields
return Object.keys(this).every(key => this[key] !== null);
}
};
// Base class
class Entity {
constructor(id) {
this.id = id;
}
}
// Combine mixins
class User extends Serializable(Validatable(Entity)) {
constructor(id, name, email) {
super(id);
this.name = name;
this.email = email;
}
}
const user = new User(1, 'Alice', 'alice@example.com');
user.serialize(); // '{"id":1,"name":"Alice","email":"alice@example.com"}'
user.validate(); // trueInterview Questions
Q: What does the class keyword actually compile to?
A: It compiles to a function (constructor) with methods on .prototype. class Foo extends Bar sets up the prototype chain so instances of Foo have Bar.prototype in their chain, and Foo itself inherits static methods from Bar.
Q: What does super() do?
A: In a derived class constructor, super() calls the parent class constructor with this bound to the new instance being created. Must be called before accessing this. super.method() calls parent's method.
Q: What are the 4 steps of the new keyword?
A: 1. Create empty object. 2. Set its [[Prototype]] to Constructor.prototype. 3. Call constructor with this = new object. 4. Return the object (or the explicit object return value if constructor returns one).
Q: How are private class fields different from closure-based privacy?
A: #privateFields are truly private — inaccessible outside the class even with creative hacks, and they show up as private in DevTools. Closure-based privacy is "privacy by convention" — technically accessible via DevTools, and requires factory function pattern (no prototype sharing).