059: Prototypes, Prototypal Inheritance, and Classes
Outcomes
By the end of this lesson, you can:
- explain prototype lookup;
- distinguish an object's own properties from inherited properties;
- use
Object.getPrototypeOf()safely for inspection; - explain constructor functions and
.prototype; - build classes with constructors, methods, inheritance, static methods, and private fields;
- understand that JavaScript classes are built on the prototype system.
Mental Model: Objects Can Delegate Lookup
When JavaScript cannot find a property directly on an object, it may continue searching through that object's prototype chain.
const animal = {
describe() {
return `${this.name} makes a sound`;
},
};
const dog = Object.create(animal);
dog.name = "Bruno";
console.log(dog.describe());
describe is not an own property of dog. It is found through the prototype chain.
Inspecting Ownership and Prototypes
console.log(Object.hasOwn(dog, "name")); // true
console.log(Object.hasOwn(dog, "describe")); // false
console.log(Object.getPrototypeOf(dog) === animal); // true
Avoid using __proto__ in application code. Use standard APIs such as Object.getPrototypeOf() and Object.setPrototypeOf() when you genuinely need them.
Prototype Chain
dog ↓ animal ↓ Object.prototype ↓ null
Eventually lookup ends at null.
Constructor Functions
Before class syntax, constructor functions were a common pattern.
function Product(name, price) {
this.name = name;
this.price = price;
}
Product.prototype.getLabel = function () {
return `${this.name} - ₹${this.price}`;
};
const tea = new Product("Tea", 20);
console.log(tea.getLabel());
The method is shared through Product.prototype instead of being recreated for every instance.
Class Syntax
class Product {
constructor(name, price) {
this.name = name;
this.price = price;
}
getLabel() {
return `${this.name} - ₹${this.price}`;
}
}
const tea = new Product("Tea", 20);
Class methods are still stored on the class's prototype.
console.log(
Object.getPrototypeOf(tea) === Product.prototype
); // true
Inheritance
class Product {
constructor(name, price) {
this.name = name;
this.price = price;
}
getLabel() {
return `${this.name} - ₹${this.price}`;
}
}
class DiscountedProduct extends Product {
constructor(name, price, discountPercent) {
super(name, price);
this.discountPercent = discountPercent;
}
getFinalPrice() {
return this.price * (1 - this.discountPercent / 100);
}
}
super() must run before using this in a derived constructor.
Method Overriding and super
class DiscountedProduct extends Product {
getLabel() {
return `${super.getLabel()} (${this.discountPercent}% off)`;
}
}
Static Methods
Static methods belong to the class constructor, not instances.
class Money {
static round(value) {
return Math.round(value * 100) / 100;
}
}
Money.round(12.345);
Use static methods for behavior associated with the abstraction but not with one specific instance.
Private Fields
class Wallet {
#balance = 0;
deposit(amount) {
if (amount <= 0) {
throw new Error("Amount must be positive");
}
this.#balance += amount;
}
get balance() {
return this.#balance;
}
}
Private fields use # syntax and are enforced by the language.
Getters and Setters
class Order {
constructor(items) {
this.items = items;
}
get total() {
return this.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
}
Use accessors when property-like syntax genuinely improves the API. Avoid hiding expensive or surprising side effects behind a getter.
Composition versus Inheritance
Inheritance is not automatically the best reuse mechanism.
function createPricedProduct(product, pricingPolicy) {
return {
...product,
getPrice() {
return pricingPolicy(product);
},
};
}
Composition can produce smaller, more flexible dependencies.
Choose based on the domain, not because one approach appears more "advanced."
Prototype Pollution Awareness
Objects that inherit from Object.prototype can be affected by unsafe merging patterns when attacker-controlled keys such as __proto__ are accepted.
Do not blindly copy untrusted properties into sensitive objects.
for (const [key, value] of Object.entries(untrusted)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") {
continue;
}
target[key] = value;
}
Real applications should use well-reviewed libraries and robust validation rather than ad-hoc filters for security-sensitive merging.
Metaprogramming with Proxy and Reflect
Proxy can intercept fundamental object operations. Reflect exposes corresponding operations as functions.
const target = {
price: 100,
};
const product = new Proxy(target, {
get(object, property, receiver) {
console.log("read:", property);
return Reflect.get(
object,
property,
receiver
);
},
set(object, property, value, receiver) {
if (property === "price" && value < 0) {
throw new RangeError("Price cannot be negative");
}
return Reflect.set(
object,
property,
value,
receiver
);
},
});
console.log(product.price);
product.price = 120;
A proxy can trap operations such as:
- property reads/writes;
inchecks;- deletion;
- enumeration-related behavior;
- function calls;
- construction.
This power comes with complexity.
Do not use a proxy merely to avoid writing explicit application methods. Hidden interception can make debugging, identity assumptions, private fields, and performance harder to reason about.
Why Reflect
Inside a trap, Reflect usually expresses the default operation more accurately than manually reconstructing it.
const logging = new Proxy(target, {
has(object, property) {
console.log("checking", property);
return Reflect.has(object, property);
},
});
Reflect is also useful on its own for dynamic operations:
Reflect.get(product, "price");
Reflect.set(product, "price", 140);
Reflect.ownKeys(product);
For ordinary CRUD/domain code, normal property access is clearer. Learn proxies because frameworks, reactivity systems, validation layers, mocks, and advanced libraries may use them.
Prototype Debugging Workflow
When a property result surprises you:
console.log(Object.hasOwn(object, "status"));
console.log(Object.getPrototypeOf(object));
console.log(
Object.getOwnPropertyDescriptor(
object,
"status"
)
);
Then walk upward deliberately:
let current = object;
while (current !== null) {
console.log(current);
current = Object.getPrototypeOf(current);
}
This is more reliable than guessing whether the value came from the instance, class prototype, parent prototype, or Object.prototype.
Worked Example: Order Hierarchy
class Order {
constructor(id, items = []) {
this.id = id;
this.items = items;
}
addItem(item) {
this.items.push(item);
}
get total() {
return this.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
}
class DeliveryOrder extends Order {
constructor(id, items, deliveryFee) {
super(id, items);
this.deliveryFee = deliveryFee;
}
get total() {
return super.total + this.deliveryFee;
}
}
const order = new DeliveryOrder(
"ORD-1",
[{ price: 100, quantity: 2 }],
30
);
console.log(order.total); // 230
Advanced Notes: Property Descriptors and Prototype-Safe APIs
Every ordinary property has descriptor metadata.
const product = {};
Object.defineProperty(product, "sku", {
value: "TEA-001",
writable: false,
enumerable: true,
configurable: false,
});
console.log(
Object.getOwnPropertyDescriptor(product, "sku")
);
Descriptors control:
valueor getter/setter behavior;- writability;
- enumerability;
- configurability.
Most application code should use normal property syntax, but descriptors explain how many built-in and framework behaviors work.
Prototype methods versus instance fields
class Counter {
increment() {
// one shared prototype method
}
reset = () => {
// typically one function per instance
};
}
Instance arrow fields can solve callback binding problems, but they have different memory/identity characteristics from prototype methods. Use them intentionally.
instanceof
const product = new Product("Tea", 20);
console.log(product instanceof Product); // true
instanceof follows the prototype chain. It can become unreliable across realms (for example, certain iframe boundaries) and is not a substitute for validating external data.
Factory alternative
function createProduct(name, price) {
return {
name,
price,
getLabel() {
return `${name} - ₹${price}`;
},
};
}
Factories, classes, and plain objects are all valid tools. Choose the simplest model that expresses ownership, lifecycle, and behavior clearly.
Mistakes and Debugging
- assuming classes replace prototypes;
- defining shared methods inside constructors unnecessarily;
- using inheritance where composition would be simpler;
- forgetting
super()in derived constructors; - relying on inherited properties when you need own-property checks;
- mutating prototypes at runtime without a strong reason.
Best Practices
- Understand prototypes even if you mostly write classes.
- Prefer clear, shallow inheritance trees.
- Use
Object.hasOwn()for ownership checks. - Keep constructors focused on valid initialization.
- Favor composition when behavior needs to vary independently.
- Treat untrusted object merging as a security boundary.
Exercises
Core
Create an object with Object.create() and prove where a method is found.
Practice
Build Product and DiscountedProduct classes with a computed final price.
Professional Extension
Refactor the inheritance example into composition and compare the trade-offs.
Recap
JavaScript's inheritance model is prototype-based. class is a more structured syntax on top of that model, not a separate object system.
