The Ultimate Guide to JavaScript Prototype Chaining

The Ultimate Guide to JavaScript Prototype Chaining

D
dongAuthor
13 min read

From core concepts to practical application of JavaScript prototype chaining! We’ll explain memory-efficient inheritance structures and the difference between prototype and __proto__ with easy-to-understand code examples.

If you’re a JavaScript developer, one of the concepts you must understand is prototype chaining. This mechanism is at the core of object-oriented programming in JavaScript and can significantly improve your code’s reusability and memory efficiency.

Many developers, accustomed to the ES6 class syntax, sometimes overlook the importance of prototypes. However, since class syntax itself is built on top of prototypes, a solid understanding of prototype chaining will give you a deeper insight into how JavaScript works.

In this article, we’ll cover everything from the basic concepts of prototype chaining to its practical applications, providing content you can immediately use in your work. We’ll walk through it step-by-step with code examples, so if you follow along to the end, you’ll be on your way to mastering prototype chaining!

When JavaScript Meets Object-Oriented Programming

JavaScript is a multi-paradigm language. It supports both functional and object-oriented programming. However, it uses a different approach from traditional object-oriented languages like Java or C++.

Traditional OOP languages use class-based inheritance. You create a blueprint called a class and then create objects based on that blueprint. JavaScript, on the other hand, uses prototype-based inheritance.

// Concept in a traditional class-based language (not actual JavaScript code)
class Animal {
  constructor(name) {
    this.name = name;
  }
  
  speak() {
    console.log(`${this.name} makes a sound.`);
  }
}

// JavaScript's prototype-based approach
function Animal(name) {
  this.name = name;
}

Animal.prototype.speak = function() {
  console.log(`${this.name} makes a sound.`);
};

In JavaScript, every object can directly inherit from another object. This is the essence of prototype-based programming.

What is Prototype-Based Programming?

Prototype-based programming is a paradigm that allows for object creation and inheritance without classes. In JavaScript, every object has a hidden link that can reference another object.

Features of Prototype-Based Programming

  1. Dynamic Inheritance: You can change an object’s prototype at runtime.
  2. Flexibility: You can create inheritance relationships between objects without declaring a class.
  3. Memory Efficiency: You can save memory by sharing common methods in the prototype.
// Prototype-based inheritance example
const animal = {
  type: 'Animal',
  speak() {
    console.log(`${this.name} is an ${this.type}.`);
  }
};

const dog = Object.create(animal);
dog.name = 'Doggy';
dog.breed = 'Jindo';

dog.speak(); // "Doggy is an Animal."

The advantage of this approach is its great flexibility. You can dynamically change an object’s structure as needed and implement inheritance without complex class hierarchies.

A Deep Dive into the Function’s prototype Property

In JavaScript, functions are special objects. Every function has a property called prototype. This prototype property points to the prototype object that will be inherited by objects created when that function is used as a constructor.

function Person(name) {
  this.name = name;
}

console.log(Person.prototype); // {constructor: ƒ}
console.log(typeof Person.prototype); // "object"

When a function is created, the JavaScript engine automatically creates a prototype object for it. This prototype object, by default, only has a constructor property.

Utilizing the prototype Property

By using the prototype property, you can define methods and properties that all instances created by the constructor function can share.

function Car(brand, model) {
  this.brand = brand;
  this.model = model;
}

// Add methods to the prototype
Car.prototype.getInfo = function() {
  return `${this.brand} ${this.model}`;
};

Car.prototype.start = function() {
  console.log(`Starting the ${this.getInfo()}.`);
};

const myCar = new Car('Hyundai', 'Sonata');
const yourCar = new Car('Kia', 'K5');

myCar.start(); // "Starting the Hyundai Sonata."
yourCar.start(); // "Starting the Kia K5."

// Check if both objects share the same method
console.log(myCar.start === yourCar.start); // true

The major advantage here is memory efficiency. If you were to define methods individually on each instance, a new method would be created in memory for each instance. However, using prototypes allows all instances to share a single method.

Understanding __proto__ and the [[Prototype]] Internal Slot

Every object in JavaScript has an internal slot called [[Prototype]]. This is a hidden link that points to the object’s prototype. Many browsers provide access to this internal slot through the __proto__ property.

The Difference Between __proto__ and prototype

These two are often confused, but they must be clearly distinguished.

function Animal(name) {
  this.name = name;
}

const dog = new Animal('Doggy');

// prototype: A property of a function (only exists on constructor functions)
console.log(Animal.prototype); // {constructor: ƒ}

// __proto__: A property of an object (exists on all objects)
console.log(dog.__proto__); // {constructor: ƒ}

// They both point to the same object
console.log(Animal.prototype === dog.__proto__); // true

Verifying the Actual Prototype Chain

function Shape(type) {
  this.type = type;
}

Shape.prototype.getType = function() {
  return this.type;
};

function Circle(radius) {
  Shape.call(this, 'Circle');
  this.radius = radius;
}

// Set up inheritance for Circle from Shape
Circle.prototype = Object.create(Shape.prototype);
Circle.prototype.constructor = Circle;

Circle.prototype.getArea = function() {
  return Math.PI * this.radius * this.radius;
};

const myCircle = new Circle(5);

// Check the prototype chain
console.log(myCircle.__proto__ === Circle.prototype); // true
console.log(myCircle.__proto__.__proto__ === Shape.prototype); // true
console.log(myCircle.__proto__.__proto__.__proto__ === Object.prototype); // true

How Prototype Chaining Works

Prototype chaining is the mechanism JavaScript uses to find an object’s properties or methods. If a desired property is not found on the object itself, it travels up the prototype chain to find it.

Property Lookup Process

  1. First, search the object’s own properties.
  2. If not found, search the prototype object pointed to by __proto__.
  3. If still not found, search that prototype’s prototype.
  4. If the property is not found even after reaching Object.prototype, return undefined.
function Animal(name) {
  this.name = name;
}

Animal.prototype.speak = function() {
  console.log(`${this.name} makes a sound.`);
};

function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
  console.log('Woof!');
};

const myDog = new Dog('Buddy', 'Jindo');

// Simulating the property lookup process
console.log(myDog.name); // Step 1: Found on myDog object - "Buddy"
console.log(myDog.breed); // Step 1: Found on myDog object - "Jindo"
myDog.bark(); // Step 2: Found on Dog.prototype - "Woof!"
myDog.speak(); // Step 3: Found on Animal.prototype - "Buddy makes a sound."
console.log(myDog.toString()); // Step 4: Found on Object.prototype

Dynamic Prototype Modification

Prototypes can be dynamically modified at runtime. This is a very powerful feature, but it should be used with caution.

function User(name) {
  this.name = name;
}

const user1 = new User('John Doe');
const user2 = new User('Jane Smith');

// Add a method to the prototype later
User.prototype.greet = function() {
  console.log(`Hello, I'm ${this.name}.`);
};

// Even already created objects can use the new method
user1.greet(); // "Hello, I'm John Doe."
user2.greet(); // "Hello, I'm Jane Smith."

Constraints and Caveats of Prototype Chaining

While powerful, prototype chaining has some constraints and points to be aware of.

Preventing Circular References

JavaScript does not allow circular references in the prototype chain.

const obj1 = {};
const obj2 = {};

obj1.__proto__ = obj2;
// obj2.__proto__ = obj1; // TypeError: Cyclic __proto__ value

Performance Considerations

The longer the prototype chain, the longer the property lookup time. This can affect performance, especially for frequently accessed properties.

// Caching example for performance
function ExpensiveOperation() {}

ExpensiveOperation.prototype.calculate = function() {
  if (this._cachedResult) {
    return this._cachedResult;
  }
  
  // Perform complex calculation
  this._cachedResult = Math.random() * 1000;
  return this._cachedResult;
};

const operation = new ExpensiveOperation();
console.log(operation.calculate()); // First call: performs calculation
console.log(operation.calculate()); // Second call: returns cached result

Property Modification and Deletion Limits

Properties accessed through the prototype chain are read-only. Modification or deletion acts directly on the object itself.

const parent = { x: 1 };
const child = Object.create(parent);

console.log(child.x); // 1 (inherited from parent)

child.x = 2; // Creates a new 'x' property on the child object
console.log(child.x); // 2
console.log(parent.x); // 1 (not changed)

delete child.x;
console.log(child.x); // 1 (inherits from parent again)

Class-Based vs. Prototype-Based Inheritance

Let’s compare the class syntax introduced in ES6 with traditional prototype-based inheritance.

Class Syntax (ES6+)

class Animal {
  constructor(name) {
    this.name = name;
  }
  
  speak() {
    console.log(`${this.name} makes a sound.`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }
  
  bark() {
    console.log('Woof!');
  }
}

const myDog = new Dog('Buddy', 'Jindo');

Prototype-Based Implementation

function Animal(name) {
  this.name = name;
}

Animal.prototype.speak = function() {
  console.log(`${this.name} makes a sound.`);
};

function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
  console.log('Woof!');
};

const myDog = new Dog('Buddy', 'Jindo');

Pros and Cons of Each Approach

Pros of Class Syntax:

  • More intuitive and readable syntax
  • Similar structure to other object-oriented languages
  • Easy access to the parent class with the super keyword

Pros of Prototype-Based:

  • More flexible inheritance structure
  • Ability to dynamically change inheritance relationships at runtime
  • Finer control over memory efficiency

Practical Benefits of Prototype Chaining

Memory Savings and Performance Optimization

Using prototypes allows all instances to share methods, making memory usage more efficient.

// Inefficient way
function User(name) {
  this.name = name;
  this.greet = function() { // Creates a function for each instance
    console.log(`Hello, I'm ${this.name}.`);
  };
}

// Efficient way
function User(name) {
  this.name = name;
}

User.prototype.greet = function() { // Shared by all instances
  console.log(`Hello, I'm ${this.name}.`);
};

// Memory usage comparison
const users1 = Array(1000).fill().map((_, i) => new User(`User${i}`));
// First way: 1000 greet functions created in memory
// Second way: 1 greet function shared by all instances

Improved Code Reusability

Prototype chaining makes it easy to reuse common functionality.

// Base class with common functionality
function BaseEntity() {}

BaseEntity.prototype.save = function() {
  console.log(`Saving ${this.constructor.name}.`);
};

BaseEntity.prototype.delete = function() {
  console.log(`Deleting ${this.constructor.name}.`);
};

// Specialized classes
function User(name) {
  this.name = name;
}
User.prototype = Object.create(BaseEntity.prototype);
User.prototype.constructor = User;

function Product(name, price) {
  this.name = name;
  this.price = price;
}
Product.prototype = Object.create(BaseEntity.prototype);
Product.prototype.constructor = Product;

const user = new User('John Doe');
const product = new Product('Laptop', 1000);

user.save(); // "Saving User."
product.delete(); // "Deleting Product."

Methods for Managing Prototypes

Object.getPrototypeOf and Object.setPrototypeOf

You can use these methods to safely get and set an object’s prototype.

const animal = {
  type: 'Animal',
  speak() {
    console.log(`${this.name} is an ${this.type}.`);
  }
};

const dog = { name: 'Doggy' };

// Set the prototype
Object.setPrototypeOf(dog, animal);

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

dog.speak(); // "Doggy is an Animal."

Using Object.create

Object.create is the cleanest way to create a new object with a specified prototype.

const vehiclePrototype = {
  start() {
    console.log(`Starting the ${this.brand} ${this.model}.`);
  },
  
  stop() {
    console.log(`Stopping the ${this.brand} ${this.model}.`);
  }
};

// Create an object with a specified prototype
const car = Object.create(vehiclePrototype, {
  brand: { value: 'Hyundai', writable: true },
  model: { value: 'Sonata', writable: true },
  year: { value: 2023, writable: true }
});

car.start(); // "Starting the Hyundai Sonata."

Safely Traversing the Prototype Chain

function walkPrototypeChain(obj) {
  const chain = [];
  let current = obj;
  
  while (current !== null) {
    chain.push(current.constructor?.name || 'Anonymous');
    current = Object.getPrototypeOf(current);
  }
  
  return chain;
}

function Animal(name) { this.name = name; }
function Dog(name, breed) { 
  Animal.call(this, name); 
  this.breed = breed; 
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

const myDog = new Dog('Buddy', 'Jindo');
console.log(walkPrototypeChain(myDog)); 
// ['Dog', 'Animal', 'Object']

Practical Prototype Inheritance Examples

Implementing a Plugin System

// Base plugin class
function Plugin(name) {
  this.name = name;
  this.enabled = false;
}

Plugin.prototype.enable = function() {
  this.enabled = true;
  console.log(`${this.name} plugin enabled.`);
};

Plugin.prototype.disable = function() {
  this.enabled = false;
  console.log(`${this.name} plugin disabled.`);
};

// Specialized plugins
function AuthPlugin() {
  Plugin.call(this, 'Authentication');
}
AuthPlugin.prototype = Object.create(Plugin.prototype);
AuthPlugin.prototype.constructor = AuthPlugin;

AuthPlugin.prototype.authenticate = function(user) {
  if (!this.enabled) {
    throw new Error('Authentication plugin is disabled.');
  }
  console.log(`Authenticating user: ${user}.`);
};

function LoggingPlugin() {
  Plugin.call(this, 'Logging');
}
LoggingPlugin.prototype = Object.create(Plugin.prototype);
LoggingPlugin.prototype.constructor = LoggingPlugin;

LoggingPlugin.prototype.log = function(message) {
  if (!this.enabled) return;
  console.log(`[LOG] ${message}`);
};

// Usage example
const authPlugin = new AuthPlugin();
const loggingPlugin = new LoggingPlugin();

authPlugin.enable();
loggingPlugin.enable();

authPlugin.authenticate('John Doe');
loggingPlugin.log('User logged in.');

Implementing the Mixin Pattern

// Mixin objects
const Flyable = {
  fly() {
    console.log(`${this.name} is flying.`);
  }
};

const Swimmable = {
  swim() {
    console.log(`${this.name} is swimming.`);
  }
};

// Helper function to apply mixins
function mixin(target, ...sources) {
  sources.forEach(source => {
    Object.getOwnPropertyNames(source).forEach(name => {
      if (name !== 'constructor') {
        target.prototype[name] = source[name];
      }
    });
  });
  return target;
}

// Base Animal class
function Animal(name) {
  this.name = name;
}

// Duck class (can fly and swim)
function Duck(name) {
  Animal.call(this, name);
}
Duck.prototype = Object.create(Animal.prototype);
Duck.prototype.constructor = Duck;

// Apply mixins
mixin(Duck, Flyable, Swimmable);

const donald = new Duck('Donald');
donald.fly(); // "Donald is flying."
donald.swim(); // "Donald is swimming."

Implementing the Decorator Pattern

// Base Coffee class
function Coffee() {
  this.cost = 1.00;
  this.description = 'Basic Coffee';
}

Coffee.prototype.getCost = function() {
  return this.cost;
};

Coffee.prototype.getDescription = function() {
  return this.description;
};

// Base Decorator class
function CoffeeDecorator(coffee) {
  this.coffee = coffee;
}

CoffeeDecorator.prototype.getCost = function() {
  return this.coffee.getCost();
};

CoffeeDecorator.prototype.getDescription = function() {
  return this.coffee.getDescription();
};

// Concrete decorators
function MilkDecorator(coffee) {
  CoffeeDecorator.call(this, coffee);
}
MilkDecorator.prototype = Object.create(CoffeeDecorator.prototype);
MilkDecorator.prototype.constructor = MilkDecorator;

MilkDecorator.prototype.getCost = function() {
  return this.coffee.getCost() + 0.50;
};

MilkDecorator.prototype.getDescription = function() {
  return this.coffee.getDescription() + ', Milk';
};

function SugarDecorator(coffee) {
  CoffeeDecorator.call(this, coffee);
}
SugarDecorator.prototype = Object.create(CoffeeDecorator.prototype);
SugarDecorator.prototype.constructor = SugarDecorator;

SugarDecorator.prototype.getCost = function() {
  return this.coffee.getCost() + 0.20;
};

SugarDecorator.prototype.getDescription = function() {
  return this.coffee.getDescription() + ', Sugar';
};

// Usage example
let myCoffee = new Coffee();
myCoffee = new MilkDecorator(myCoffee);
myCoffee = new SugarDecorator(myCoffee);

console.log(myCoffee.getDescription()); // "Basic Coffee, Milk, Sugar"
console.log(myCoffee.getCost()); // 1.7

Mastering Prototype Chaining

Prototype chaining is one of JavaScript’s core concepts. Understanding it properly will enable you to write more efficient and flexible code.

Here’s a summary of the key points:

  1. Prototype-Based Inheritance: JavaScript uses prototype-based inheritance, not class-based.
  2. Memory Efficiency: Using prototypes allows you to share methods and save memory.
  3. Dynamic Nature: You can change prototypes at runtime, allowing for very flexible programming.
  4. Chain Traversal: You need to understand the mechanism of traversing up the prototype chain during property lookups.
  5. Safe Usage: You can manage prototypes safely using methods like Object.getPrototypeOf, Object.setPrototypeOf, and Object.create.

Mastering prototype chaining will give you a deeper understanding of ES6 class syntax and make it easier to grasp the inner workings of JavaScript libraries and frameworks. You’ll be able to confidently handle complex inheritance structures and design patterns you encounter in your work.

Start actively using prototype chaining in your projects today. You’ll find yourself writing cleaner and more efficient code!

The Ultimate Guide to JavaScript Prototype Chaining | devdong