JavaScript Series #33: Unraveling Prototypes and Prototypal Inheritance
Welcome back to our JavaScript series! Today, we're diving deep into one of JavaScript's most fundamental and often misunderstood concepts: Prototypes and Prototypal Inheritance. Understanding prototypes is key to truly grasping how objects work, how inheritance is achieved, and even what goes on behind the scenes with modern JavaScript classes.
What Exactly is a Prototype?
In JavaScript, every object has an internal slot called [[Prototype]], which is a reference to another object. This referenced object is known as the "prototype" of the original object. When you try to access a property or method on an object, and that property isn't found directly on the object itself, JavaScript will look for it on its prototype object. If it's still not found, it will look on the prototype's prototype, and so on, forming what's known as the prototype chain.
This mechanism is how JavaScript implements inheritance. Instead of classes defining a blueprint for objects (as in classical inheritance), JavaScript objects directly inherit properties and methods from other objects.
__proto__ vs. prototype: A Crucial Distinction
This is where much of the confusion arises. Let's clarify:
-
[[Prototype]]: This is the actual internal slot that references an object's prototype. It's an internal property, not directly accessible in standard JavaScript code. -
__proto__: This is a legacy (and now deprecated) accessor property that exposes the[[Prototype]]of an object. While still widely supported in browsers for backward compatibility, it's generally discouraged to use it for direct manipulation due to performance implications and potential confusion. You might see it in debugging tools. The modern way to access or set an object's prototype is viaObject.getPrototypeOf()andObject.setPrototypeOf(). -
prototype(on constructor functions): This property exists only on functions, and specifically on functions intended to be used as constructor functions (e.g., when called with thenewkeyword). The value of a constructor function'sprototypeproperty is the object that will become the[[Prototype]]of all objects created using that constructor. This is where methods and shared properties are typically defined for instances.
Illustrating with a Simple Object
Let's create a simple object and see its prototype:
const myObject = {}; // Creates a plain object
// Using Object.getPrototypeOf to access the internal [[Prototype]]
console.log(Object.getPrototypeOf(myObject));
// Expected output: [Object: null prototype] {} (or similar, depending on environment)
// This is Object.prototype, the base prototype for all plain objects.
// You might also see this deprecated way in older code or debugging:
// console.log(myObject.__proto__);
// Expected output: [Object: null prototype] {}
As you can see, a plain object's prototype is Object.prototype. This is the top of the prototype chain for most objects created directly or with object literals.
The prototype Property and Constructor Functions
When you define a function that you intend to use as a constructor (by calling it with new), that function automatically gets a prototype property. This prototype property is an object itself, and any properties or methods you add to it will be inherited by all instances created from that constructor function.
This is immensely powerful for memory efficiency, as methods don't need to be duplicated on every instance; they can be shared via the prototype chain.
Example: A Constructor Function and its Prototype
function Car(make, model) {
this.make = make;
this.model = model;
// this.start = function() { ... } // BAD practice: creates new function for each instance
}
// Add methods to the Car's prototype
// These methods will be shared by all Car instances
Car.prototype.start = function() {
console.log(`${this.make} ${this.model} is starting...`);
};
Car.prototype.drive = function() {
console.log(`${this.make} ${this.model} is driving.`);
};
const car1 = new Car("Honda", "Civic");
const car2 = new Car("Tesla", "Model 3");
car1.start(); // Honda Civic is starting...
car2.drive(); // Tesla Model 3 is driving.
// Check the prototype chain
console.log(Object.getPrototypeOf(car1) === Car.prototype); // true
console.log(Object.getPrototypeOf(car2) === Car.prototype); // true
// Verify that 'start' and 'drive' are not directly on the instance, but on its prototype
console.log(car1.hasOwnProperty('start')); // false
console.log(car1.hasOwnProperty('make')); // true
In this example, car1 and car2 don't have their own start or drive methods. When car1.start() is called, JavaScript looks for start on car1. It doesn't find it, so it looks on car1's prototype (which is Car.prototype), finds the method there, and executes it with this bound to car1.
Prototypal Inheritance in Action: The Prototype Chain
The prototype chain is the core mechanism of prototypal inheritance. When you try to access a property or method:
- JavaScript first checks if the property exists directly on the object itself.
- If not, it moves up to the object's
[[Prototype]]and checks there. - This process continues up the chain until it either finds the property or reaches the end of the chain (
null). - If the property is not found anywhere in the chain, it returns
undefined.
The ultimate ancestor in most prototype chains is Object.prototype, which itself has a [[Prototype]] of null, marking the end of the chain.
Extending the Prototype Chain
Let's create another layer of inheritance:
function ElectricCar(make, model, batteryCapacity) {
// Call the parent constructor to initialize shared properties
Car.call(this, make, model);
this.batteryCapacity = batteryCapacity;
}
// Set ElectricCar's prototype to an object whose prototype is Car.prototype
// This establishes the inheritance chain: ElectricCar.prototype -> Car.prototype -> Object.prototype
ElectricCar.prototype = Object.create(Car.prototype);
// Correctly set the constructor back to ElectricCar
ElectricCar.prototype.constructor = ElectricCar;
// Add a specific method for ElectricCar
ElectricCar.prototype.charge = function() {
console.log(`${this.make} ${this.model} is charging (${this.batteryCapacity} kWh).`);
};
const tesla = new ElectricCar("Tesla", "Model S", 100);
tesla.start(); // Inherited from Car.prototype: Tesla Model S is starting...
tesla.drive(); // Inherited from Car.prototype: Tesla Model S is driving.
tesla.charge(); // Defined on ElectricCar.prototype: Tesla Model S is charging (100 kWh).
console.log(Object.getPrototypeOf(tesla) === ElectricCar.prototype); // true
console.log(Object.getPrototypeOf(ElectricCar.prototype) === Car.prototype); // true
console.log(Object.getPrototypeOf(Car.prototype) === Object.prototype); // true
console.log(Object.getPrototypeOf(Object.prototype)); // null (end of chain)
Here, when tesla.start() is called, JavaScript looks on tesla, then on ElectricCar.prototype, then on Car.prototype (where it finds start), and finally executes it.
Object.create() for Direct Prototypal Linkage
Object.create(proto, [propertiesObject]) is a powerful method that creates a new object with the specified prototype object and properties. It's the most direct way to establish a prototypal relationship without using constructor functions or the new keyword.
const personPrototype = {
greet() {
console.log(`Hello, my name is ${this.name}.`);
},
introduce(topic) {
console.log(`I love talking about ${topic}.`);
}
};
const john = Object.create(personPrototype);
john.name = "John Doe";
john.greet(); // Hello, my name is John Doe.
john.introduce("JavaScript"); // I love talking about JavaScript.
console.log(Object.getPrototypeOf(john) === personPrototype); // true
This approach is often preferred in modern JavaScript when you want to explicitly define an object's prototype without constructor-like behavior, promoting a more "object-to-object" inheritance style.
Classes: Syntactic Sugar over Prototypes
With ES2015, JavaScript introduced the class keyword, providing a more familiar syntax for object-oriented programming. However, it's crucial to understand that JavaScript classes are merely syntactic sugar over its existing prototypal inheritance model. They do not introduce a new object model.
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a sound.`);
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // Calls the parent Animal constructor
this.breed = breed;
}
speak() { // Overrides the speak method
console.log(`${this.name} barks!`);
}
fetch() {
console.log(`${this.name} fetches the ball.`);
}
}
const buddy = new Dog("Buddy", "Golden Retriever");
buddy.speak(); // Buddy barks! (calls Dog.prototype.speak)
buddy.fetch(); // Buddy fetches the ball.
console.log(Object.getPrototypeOf(buddy) === Dog.prototype); // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true
// Methods defined inside a class are actually placed on the class's prototype
console.log(buddy.hasOwnProperty('speak')); // false (it's on the prototype)
console.log(Dog.prototype.hasOwnProperty('speak')); // true
As you can see, methods defined in a class (like speak and fetch) are added to the class's prototype property, just like with traditional constructor functions. The extends keyword handles setting up the prototype chain between Dog.prototype and Animal.prototype.
Benefits of Prototypal Inheritance
- Memory Efficiency: Methods and shared properties are stored once on the prototype, rather than being duplicated on every instance.
- Dynamic Nature: You can add or modify properties/methods on a prototype even after objects have been created from it, and those changes will immediately be reflected in all existing instances (unless the instance has its own property with the same name).
- Flexibility: It allows for more flexible and dynamic object composition compared to rigid class hierarchies.
- Simplicity (Once Understood): While initially confusing, the core concept of objects inheriting from other objects is quite elegant and less verbose than class-based systems for many patterns.
Conclusion
Prototypes and prototypal inheritance are at the very heart of JavaScript's object model. By understanding how the prototype chain works, the distinction between [[Prototype]], __proto__, and the prototype property, and how Object.create() and even ES6 classes leverage this system, you gain a deeper insight into the language. This knowledge is not just academic; it empowers you to write more efficient, flexible, and robust JavaScript code.
Keep experimenting with these concepts, and you'll soon find them intuitive and powerful!