FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·059·9 MIN READ

059: Prototypes, Prototypal Inheritance, and Classes

TOPICS COVERED: 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 you read a property, JavaScript first checks the object itself. If it does not find the property there, JavaScript can continue up the object's prototype chain. That fallback lookup is the behavior behind prototypal inheritance.

js
const animal = {
  describe() {
    return `${this.name} makes a sound`;
  },
};

const dog = Object.create(animal);
dog.name = "Bruno";

console.log(dog.describe());

The useful distinction is that describe is not an own property of dog. dog delegates the lookup to animal, where the method is found. The method still runs with dog as this because it was called as dog.describe().

Inspecting Ownership and Prototypes

When debugging an object, first separate properties stored directly on that object from properties it can reach through inheritance.

js
console.log(Object.hasOwn(dog, "name"));     // true
console.log(Object.hasOwn(dog, "describe")); // false

console.log(Object.getPrototypeOf(dog) === animal); // true

Object.hasOwn() answers the ownership question without being confused by a property that happens to exist higher in the chain. Object.getPrototypeOf() lets you inspect the next object JavaScript will use for delegated lookup.

Avoid using __proto__ in application code. Use standard APIs such as Object.getPrototypeOf() and Object.setPrototypeOf() when you genuinely need to inspect or change a prototype. In most application code, changing prototypes at runtime is unnecessary and makes behavior harder to follow.

Prototype Chain

For the objects above, lookup follows this path:

text
dog
  ↓
animal
  ↓
Object.prototype
  ↓
null

JavaScript checks each object in order. Eventually the chain ends at null; if the property has not been found by then, the result is undefined for an ordinary property read. This chain is a lookup mechanism, not a copy of the properties from one object into another.

Constructor Functions

Before class syntax became available, constructor functions were a common way to create related objects. Calling one with new creates an object, connects it to the function's .prototype, and runs the function with the new object as this.

js
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());

getLabel is shared through Product.prototype instead of being recreated for every instance. The instance stores its own name and price, while method lookup reaches the shared prototype method. This is why the function's placement matters when you are inspecting memory usage or debugging an instance.

Class Syntax

class provides a clearer way to express the same general relationship. It does not replace the prototype system.

js
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. The constructor initializes each instance, but getLabel is not copied into each instance as a separate method.

js
console.log(
  Object.getPrototypeOf(tea) === Product.prototype
); // true

That check is a useful way to make the underlying model concrete: an object created by new Product() points at Product.prototype, and the class method is found there.

Inheritance

Use extends when one type genuinely has the behavior and substitutability of another type. The derived class receives access to the parent class's prototype methods and can add its own behavior.

js
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. It initializes the parent portion of the new object. JavaScript does not allow the derived constructor to access this first because the derived constructor has not yet established that instance.

Method Overriding and super

A subclass can replace a method while still reusing the parent implementation. super.getLabel() starts the method lookup at the parent prototype rather than calling the overriding method again.

js
class DiscountedProduct extends Product {
  getLabel() {
    return `${super.getLabel()} (${this.discountPercent}% off)`;
  }
}

This keeps the shared label formatting in one place and adds the discounted-product detail where it belongs.

Static Methods

Static methods belong to the class constructor, not to instances created from that class. They are appropriate when the operation relates to the abstraction as a whole and does not need instance state.

js
class Money {
  static round(value) {
    return Math.round(value * 100) / 100;
  }
}

Money.round(12.345);

Call this method as Money.round(...), not as a method on a Money instance. Use static methods for behavior associated with the abstraction but not with one specific instance.

Private Fields

Private fields provide language-enforced encapsulation. A field beginning with # can be accessed only by code inside the class that declares it.

js
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. They are not ordinary properties that callers can read by guessing a string key, and they do not appear as normal enumerable properties during inspection.

Getters and Setters

Getters expose a method through property-like syntax. That can make a small, derived value read naturally, as in this order total:

js
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. Do not hide expensive work, I/O, mutation, or other surprising side effects behind a getter; a caller reasonably expects reading a property to be straightforward.

Composition versus Inheritance

Inheritance is not automatically the best reuse mechanism. If behavior needs to vary independently from the data object, composition can keep the dependency smaller and easier to replace.

js
function createPricedProduct(product, pricingPolicy) {
  return {
    ...product,
    getPrice() {
      return pricingPolicy(product);
    },
  };
}

Here, the product receives pricing behavior without becoming a subclass of a pricing hierarchy. 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. A merge that treats every input key as harmless can modify object behavior beyond the object being populated.

Do not blindly copy untrusted properties into sensitive objects. At minimum, this example skips keys with special prototype-related behavior:

js
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 treating an ad-hoc filter as a complete security boundary for sensitive merging. The input is still untrusted, and the shape and meaning of accepted values need validation as well.

Metaprogramming with Proxy and Reflect

Sometimes code needs to observe or constrain fundamental object operations. Proxy can intercept those operations, while Reflect exposes the corresponding default operations as functions.

js
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;

The get trap logs a read and then delegates to the ordinary operation. The set trap rejects a negative price before delegating the write. A proxy can trap operations such as:

  • property reads/writes;
  • in checks;
  • 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. Proxies are most useful when the interception itself is the design requirement.

Why Reflect

Inside a trap, Reflect usually expresses the default operation more accurately than manually reconstructing it. Passing the receiver through, for example, helps preserve normal receiver and accessor behavior.

js
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:

js
Reflect.get(product, "price");
Reflect.set(product, "price", 140);
Reflect.ownKeys(product);

For ordinary CRUD and domain code, normal property access is clearer. Learn proxies because frameworks, reactivity systems, validation layers, mocks, and advanced libraries may use them, not because every object needs one.

Prototype Debugging Workflow

When a property result surprises you, do not start by guessing which class is responsible. Check ownership, inspect the immediate prototype, and inspect the descriptor on the object itself.

js
console.log(Object.hasOwn(object, "status"));
console.log(Object.getPrototypeOf(object));
console.log(
  Object.getOwnPropertyDescriptor(
    object,
    "status"
  )
);

Then walk upward deliberately:

js
let current = object;

while (current !== null) {
  console.log(current);
  current = Object.getPrototypeOf(current);
}

This workflow tells you whether the value came from the instance, a class prototype, a parent prototype, or Object.prototype. A missing descriptor on the current object is not evidence that the property does not exist; it may simply be inherited. Walking the chain is more reliable than reasoning from the object's apparent type alone.

Worked Example: Order Hierarchy

This example combines a constructor, a shared method, an accessor, and an overridden accessor. DeliveryOrder reuses the base order total and adds its delivery fee.

js
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

The base calculation is 100 * 2, which is 200. The derived getter calls super.total and adds 30, so the result is 230. The items array and deliveryFee are own properties of the instance; the total behavior is found through the relevant prototype methods.

Advanced Notes: Property Descriptors and Prototype-Safe APIs

Every ordinary property has descriptor metadata. Descriptors determine how a property can be read, changed, enumerated, or reconfigured.

js
const product = {};

Object.defineProperty(product, "sku", {
  value: "TEA-001",
  writable: false,
  enumerable: true,
  configurable: false,
});

console.log(
  Object.getOwnPropertyDescriptor(product, "sku")
);

Descriptors control:

  • value or 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. They are also useful during debugging when a property appears to ignore an assignment or fails to show up during enumeration.

Prototype methods versus instance fields

Class methods and instance fields have different placement and identity characteristics:

js
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 rather than assuming the two forms are interchangeable.

instanceof

js
const product = new Product("Tea", 20);

console.log(product instanceof Product); // true

instanceof follows the prototype chain to determine whether Product.prototype appears there. It can become unreliable across realms, such as certain iframe boundaries, and it is not a substitute for validating external data. An object from a different realm or an object that merely has the expected shape may require a different validation strategy.

Factory alternative

Classes are not the only way to create objects with behavior. A factory can close over values and return a plain object:

js
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. The right choice depends on whether shared prototype methods, explicit construction, encapsulated state, or straightforward data is the clearest fit.

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.

When one of these mistakes appears, inspect the object and its prototype chain instead of treating the class declaration as the whole runtime picture. In particular, use Object.hasOwn() when ownership matters and confirm where a method is actually defined.

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.

These practices keep the runtime model visible. They also make it easier to distinguish a data problem from a lookup problem when debugging.

Exercises

Core

Create an object with Object.create() and prove where a method is found. Use ownership and prototype inspection rather than relying only on the method call's output.

Practice

Build Product and DiscountedProduct classes with a computed final price. Make the derived class initialize its parent state correctly and verify the result with a concrete product.

Professional Extension

Refactor the inheritance example into composition and compare the trade-offs. Consider how the pricing behavior is supplied, how it could vary independently, and which design makes the dependency easiest to test or replace.

Recap

JavaScript's inheritance model is prototype-based. class is a more structured syntax on top of that model, not a separate object system. When a property lookup or class behavior is surprising, inspect ownership and walk the prototype chain; that runtime model explains what the syntax is doing.

Reader page: /javascript/lesson/059/prototypes-prototypal-inheritance-and-classes