Prototypes and Prototypal Inheritance in JavaScript

Prototypes are a core feature of JavaScript's object system. Unlike classical inheritance found in languages like Java or C++, JavaScript uses prototypal inheritance, where objects inherit from other objects directly. This lesson provides an in-depth exploration of prototypes, the prototype chain, and various inheritance patterns, enabling you to harness the power of JavaScript's flexible object model.

Understanding Prototypes

What is a Prototype?

  • Definition: A prototype is an object from which other objects inherit properties and methods.
  • Role in JavaScript: Every JavaScript object has an internal property called [[Prototype]] (commonly accessed via __proto__), which points to its prototype object.

Prototype vs. prototype Property

  • [[Prototype]] (or __proto__): The internal reference from an object to its prototype.
  • prototype Property: A property available on functions (specifically constructor functions) that is used when creating new objects with the new keyword.

Example:

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

Person.prototype.sayHello = function () {
  console.log(`Hello, my name is ${this.name}.`)
}

const alice = new Person('Alice')
alice.sayHello() // Outputs: Hello, my name is Alice.

Explanation:

  • Person is a constructor function.
  • Person.prototype is the prototype object for all instances created by new Person().
  • alice.__proto__ points to Person.prototype.

The Prototype Chain

How Does the Prototype Chain Work?

  • Definition: The prototype chain is a series of linked objects that JavaScript uses to find properties and methods.
  • Mechanism:
    • When accessing a property on an object, JavaScript first looks for the property on the object itself.
    • If not found, it looks up the [[Prototype]] chain until it finds the property or reaches null.

Visualization:

alice ---> Person.prototype ---> Object.prototype ---> null
  • alice inherits from Person.prototype.
  • Person.prototype inherits from Object.prototype.
  • Object.prototype is the top-level prototype, with its [[Prototype]] set to null.

Accessing Properties Through the Prototype Chain

Example:

console.log(alice.toString()) // Outputs: [object Object]

Explanation:

  • toString is not defined on alice or Person.prototype.
  • JavaScript looks up the prototype chain and finds toString on Object.prototype.

Inheritance Patterns in JavaScript

Constructor Functions and Prototypes

  • Definition: Using functions as constructors to create objects with shared properties and methods via the prototype property.

Example:

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

Animal.prototype.eat = function () {
  console.log(`${this.name} is eating.`)
}

const dog = new Animal('Dog')
dog.eat() // Outputs: Dog is eating.

Explanation:

  • Instances of Animal share methods defined on Animal.prototype.

Prototypal Inheritance with Object.create()

  • Definition: Creating a new object that directly inherits from an existing object.

Example:

const animal = {
  eat: function () {
    console.log(`${this.name} is eating.`)
  },
}

const cat = Object.create(animal)
cat.name = 'Cat'
cat.eat() // Outputs: Cat is eating.

Explanation:

  • cat's [[Prototype]] points to animal.
  • Methods and properties are inherited directly from animal.

ES6 Classes and extends

  • Definition: Syntactic sugar over prototypal inheritance introduced in ES6 for easier and cleaner inheritance.

Example:

class Vehicle {
  constructor(make) {
    this.make = make
  }

  drive() {
    console.log(`${this.make} is driving.`)
  }
}

class Car extends Vehicle {
  constructor(make, model) {
    super(make)
    this.model = model
  }

  honk() {
    console.log(`${this.make} ${this.model} says beep beep!`)
  }
}

const myCar = new Car('Toyota', 'Corolla')
myCar.drive() // Outputs: Toyota is driving.
myCar.honk() // Outputs: Toyota Corolla says beep beep!

Explanation:

  • Car extends Vehicle, inheriting its properties and methods.
  • super(make) calls the constructor of the parent class.

Mixins

  • Definition: Copying properties from one object to another to achieve inheritance.

Example:

const canFly = {
  fly: function () {
    console.log(`${this.name} is flying.`)
  },
}

function Bird(name) {
  this.name = name
}

Object.assign(Bird.prototype, canFly)

const eagle = new Bird('Eagle')
eagle.fly() // Outputs: Eagle is flying.

Explanation:

  • Object.assign copies properties from canFly to Bird.prototype.

Prototype Chain in Detail

Object Creation and Prototypes

  • Object Literals: Objects created using {} have [[Prototype]] pointing to Object.prototype.

Example:

const obj = {}
console.log(obj.__proto__ === Object.prototype) // Outputs: true

Function Prototypes

  • Function Objects: Functions in JavaScript are also objects.

Example:

function foo() {}
console.log(foo.__proto__ === Function.prototype) // Outputs: true

Custom Prototypes

  • Changing an Object's Prototype:

Example:

const parent = { greeting: 'Hello' }
const child = Object.create(parent)
console.log(child.greeting) // Outputs: Hello
  • Setting Prototype Manually:
const parent = { greeting: 'Hello' }
const child = {}
Object.setPrototypeOf(child, parent)
console.log(child.greeting) // Outputs: Hello

Inheritance Patterns Explained

Classical Inheritance Emulation

  • Constructor Inheritance:

Example:

function Parent(name) {
  this.name = name
}

Parent.prototype.sayName = function () {
  console.log(`Name: ${this.name}`)
}

function Child(name, age) {
  Parent.call(this, name) // Inherit properties
  this.age = age
}

Child.prototype = Object.create(Parent.prototype) // Inherit methods
Child.prototype.constructor = Child

Child.prototype.sayAge = function () {
  console.log(`Age: ${this.age}`)
}

const child = new Child('Frank', 10)
child.sayName() // Outputs: Name: Frank
child.sayAge() // Outputs: Age: 10

Explanation:

  • Inheritance of Properties: Using Parent.call(this, name) to inherit properties.
  • Inheritance of Methods: Setting Child.prototype to an object created from Parent.prototype.
  • Resetting Constructor: Assigning Child.prototype.constructor to Child to maintain the correct constructor reference.

Parasitic Inheritance

  • Definition: Creating a new object by augmenting an existing object.

Example:

function createAugmentedObject(original) {
  const clone = Object.create(original)
  clone.sayHello = function () {
    console.log('Hello!')
  }
  return clone
}

const original = { name: 'Grace' }
const augmented = createAugmentedObject(original)
augmented.sayHello() // Outputs: Hello!
console.log(augmented.name) // Outputs: Grace

Combination Inheritance

  • Definition: Combining constructor stealing and prototype chaining.

Example:

function SuperType(name) {
  this.name = name
  this.colors = ['red', 'blue', 'green']
}

SuperType.prototype.sayName = function () {
  console.log(this.name)
}

function SubType(name, age) {
  SuperType.call(this, name) // Inherit properties
  this.age = age
}

SubType.prototype = new SuperType() // Inherit methods
SubType.prototype.sayAge = function () {
  console.log(this.age)
}

const instance1 = new SubType('Heidi', 25)
instance1.colors.push('black')
console.log(instance1.colors) // Outputs: ['red', 'blue', 'green', 'black']
instance1.sayName() // Outputs: Heidi
instance1.sayAge() // Outputs: 25

const instance2 = new SubType('Ivan', 30)
console.log(instance2.colors) // Outputs: ['red', 'blue', 'green']

Explanation:

  • Each instance has its own copy of properties, preventing shared state issues.
  • Methods are shared via the prototype chain.

Best Practices

Prefer ES6 Classes for Inheritance

  • Benefits:
    • Cleaner syntax.
    • Easier to read and maintain.
    • Consistent with other object-oriented languages.

Example:

class Employee {
  constructor(name) {
    this.name = name
  }

  work() {
    console.log(`${this.name} is working.`)
  }
}

class Manager extends Employee {
  manage() {
    console.log(`${this.name} is managing.`)
  }
}

const manager = new Manager('Jack')
manager.work() // Outputs: Jack is working.
manager.manage() // Outputs: Jack is managing.

Use Object.create() for Simple Inheritance

  • When to Use:
    • When you need a simple prototype-based inheritance without the need for constructors.

Example:

const animal = {
  init: function (name) {
    this.name = name
  },
  makeSound: function () {
    console.log(`${this.name} makes a sound.`)
  },
}

const dog = Object.create(animal)
dog.init('Dog')
dog.makeSound() // Outputs: Dog makes a sound.

Avoid Modifying the __proto__ Property Directly

  • Reason:
    • Modifying __proto__ is discouraged due to performance and readability concerns.
    • Use Object.setPrototypeOf() or Object.create() instead.

Be Careful with Shared Properties

  • Issue:
    • Objects sharing the same prototype can inadvertently share mutable properties (like arrays or objects).

Example:

function Person() {}
Person.prototype.hobbies = []

const person1 = new Person()
const person2 = new Person()

person1.hobbies.push('Reading')
console.log(person2.hobbies) // Outputs: ['Reading']

Solution:

  • Define properties inside the constructor function to ensure each instance has its own copy.

Exercises

Exercise 1: Understanding the Prototype Chain

Question:

Given the following code, what will be the output of console.log(baz);?

function Foo() {}
Foo.prototype.bar = 'bar'

const foo = new Foo()

const baz = foo.bar
console.log(baz) // Output?

Answer:

bar

Explanation:

  • foo does not have its own bar property.
  • JavaScript looks up the prototype chain and finds bar on Foo.prototype.
  • baz is assigned the value 'bar'.

Exercise 2: Using Object.create()

Question:

Create an object car that inherits from vehicle and has its own brand property. Use Object.create() for inheritance.

const vehicle = {
  type: 'Vehicle',
  drive: function () {
    console.log(`${this.brand} is driving.`)
  },
}

// Your code here

car.drive() // Outputs: Toyota is driving.

Answer:

const car = Object.create(vehicle)
car.brand = 'Toyota'

car.drive() // Outputs: Toyota is driving.

Explanation:

  • car's prototype is set to vehicle using Object.create(vehicle).
  • car gains access to drive method from vehicle.

Exercise 3: Fixing Constructor Reference

Question:

In the following code, the constructor property of Child.prototype is incorrect. Fix it.

function Parent() {}

function Child() {}

Child.prototype = Object.create(Parent.prototype)

// Fix here

const child = new Child()
console.log(child.constructor === Child) // Should be true

Answer:

Child.prototype.constructor = Child

const child = new Child()
console.log(child.constructor === Child) // Outputs: true

Explanation:

  • After setting Child.prototype to a new object, the constructor property points to Parent.
  • Resetting Child.prototype.constructor to Child corrects this.

Exercise 4: ES6 Class Inheritance

Question:

Convert the following constructor function inheritance to use ES6 classes.

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

Animal.prototype.eat = function () {
  console.log(`${this.name} eats.`)
}

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(`${this.name} barks.`)
}

const myDog = new Dog('Buddy', 'Golden Retriever')
myDog.eat() // Outputs: Buddy eats.
myDog.bark() // Outputs: Buddy barks.

Answer:

class Animal {
  constructor(name) {
    this.name = name
  }

  eat() {
    console.log(`${this.name} eats.`)
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name)
    this.breed = breed
  }

  bark() {
    console.log(`${this.name} barks.`)
  }
}

const myDog = new Dog('Buddy', 'Golden Retriever')
myDog.eat() // Outputs: Buddy eats.
myDog.bark() // Outputs: Buddy barks.

Explanation:

  • The class syntax simplifies inheritance.
  • extends is used for inheritance, and super() calls the parent constructor.

Exercise 5: Shared Properties Issue

Question:

Identify the issue with the following code and provide a solution.

function Student() {}
Student.prototype.grades = []

const student1 = new Student()
const student2 = new Student()

student1.grades.push(90)
console.log(student2.grades) // Outputs: [90]

Answer:

Issue:

  • The grades array is shared across all instances because it's defined on the prototype.
  • Mutating grades on one instance affects all instances.

Solution:

  • Define grades inside the constructor to ensure each instance has its own copy.
function Student() {
  this.grades = []
}

Student.prototype.addGrade = function (grade) {
  this.grades.push(grade)
}

const student1 = new Student()
const student2 = new Student()

student1.addGrade(90)
console.log(student1.grades) // Outputs: [90]
console.log(student2.grades) // Outputs: []

Prototypes and prototypal inheritance are central to understanding JavaScript's object model. By mastering the prototype chain and various inheritance patterns, you can write more efficient and maintainable code. This knowledge also prepares you for technical interviews, where a deep understanding of JavaScript's inheritance mechanisms is essential.

Practice Problems

What is the purpose of the `constructor` property in an object's prototype?

Loading...

Prototype Chain BasicsDifficulty: Medium

How does the prototype chain work when accessing properties on an object?

Loading...

Explain the difference between the `__proto__` property and the `prototype` property.

Loading...

How can you implement inheritance in JavaScript using ES6 classes?

Loading...

What is prototypal inheritance in JavaScript?

Loading...

Let's continue exploring the next page. Take your time, and proceed when you're ready.

Lesson completed?

Found a bug, typo, or have feedback?

Let me know