Advanced JavaScriptFeatured

JavaScript Prototypes and Prototypal Inheritance

Deep dive into JavaScript prototypes, prototype chain, and prototypal inheritance. Learn how objects inherit properties and methods in JavaScript.

By JavaScriptDoc Team
prototypesinheritanceobjectsOOPjavascript

JavaScript Prototypes and Prototypal Inheritance

Prototypes are the mechanism by which JavaScript objects inherit features from one another. Unlike classical inheritance in languages like Java or C++, JavaScript uses prototypal inheritance, making it unique and powerful.

Understanding Prototypes

Every JavaScript object has an internal property called [[Prototype]] that references another object. This forms the basis of prototypal inheritance.

// Every object has a prototype
const obj = {};
console.log(Object.getPrototypeOf(obj)); // Object.prototype

// Arrays inherit from Array.prototype
const arr = [];
console.log(Object.getPrototypeOf(arr)); // Array.prototype
console.log(Object.getPrototypeOf(Array.prototype)); // Object.prototype

// Functions inherit from Function.prototype
function func() {}
console.log(Object.getPrototypeOf(func)); // Function.prototype

The Prototype Chain

When you access a property on an object, JavaScript looks up the prototype chain until it finds the property or reaches the end.

// Prototype chain in action
const animal = {
  type: 'animal',
  breathe() {
    console.log('Breathing...');
  },
};

const mammal = {
  type: 'mammal',
  feedMilk() {
    console.log('Feeding milk...');
  },
};

const dog = {
  name: 'Rex',
  bark() {
    console.log('Woof!');
  },
};

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

// Accessing properties through the chain
console.log(dog.name); // 'Rex' (own property)
console.log(dog.type); // 'mammal' (from mammal)
dog.breathe(); // 'Breathing...' (from animal)
dog.feedMilk(); // 'Feeding milk...' (from mammal)

// Check the prototype chain
console.log(dog.hasOwnProperty('name')); // true
console.log(dog.hasOwnProperty('type')); // false
console.log('breathe' in dog); // true (found in chain)

Constructor Functions and Prototypes

Constructor functions are the traditional way to create objects with shared methods.

// Constructor function
function Person(name, age) {
  // Instance properties
  this.name = name;
  this.age = age;
}

// Shared methods on prototype
Person.prototype.greet = function () {
  return `Hello, I'm ${this.name}`;
};

Person.prototype.birthday = function () {
  this.age++;
  return `Happy birthday! Now ${this.age} years old`;
};

// Create instances
const john = new Person('John', 30);
const jane = new Person('Jane', 25);

console.log(john.greet()); // "Hello, I'm John"
console.log(jane.greet()); // "Hello, I'm Jane"

// Both instances share the same methods
console.log(john.greet === jane.greet); // true

// Check prototype
console.log(Object.getPrototypeOf(john) === Person.prototype); // true
console.log(john instanceof Person); // true

Understanding the Constructor Property

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

const myCar = new Car('Toyota', 'Camry');

// Constructor property points back to the constructor function
console.log(myCar.constructor === Car); // true
console.log(Car.prototype.constructor === Car); // true

// Be careful when replacing the prototype
Car.prototype = {
  // Lost the constructor property!
  drive() {
    console.log('Driving...');
  },
};

// Fix by adding constructor back
Car.prototype = {
  constructor: Car,
  drive() {
    console.log('Driving...');
  },
};

// Or use Object.defineProperty
Object.defineProperty(Car.prototype, 'constructor', {
  enumerable: false,
  writable: true,
  configurable: true,
  value: Car,
});

Object.create() Method

Object.create() provides a more direct way to set up prototypal inheritance.

// Create object with specific prototype
const personProto = {
  greet() {
    return `Hello, I'm ${this.name}`;
  },
  introduce() {
    return `My name is ${this.name} and I'm ${this.age} years old`;
  },
};

// Create object with personProto as prototype
const john = Object.create(personProto);
john.name = 'John';
john.age = 30;

console.log(john.greet()); // "Hello, I'm John"

// With property descriptors
const jane = Object.create(personProto, {
  name: {
    value: 'Jane',
    writable: true,
    enumerable: true,
    configurable: true,
  },
  age: {
    value: 25,
    writable: true,
    enumerable: true,
    configurable: true,
  },
});

// Create object with null prototype
const nullProtoObj = Object.create(null);
console.log(nullProtoObj.toString); // undefined (no Object.prototype)

Classical Inheritance Pattern

Implementing inheritance with constructor functions:

// Parent constructor
function Animal(name) {
  this.name = name;
  this.energy = 100;
}

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

Animal.prototype.sleep = function (hours) {
  console.log(`${this.name} is sleeping`);
  this.energy += hours * 10;
};

// Child constructor
function Dog(name, breed) {
  // Call parent constructor
  Animal.call(this, name);
  this.breed = breed;
}

// Set up inheritance
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

// Add methods to child
Dog.prototype.bark = function () {
  console.log(`${this.name} says: Woof!`);
  this.energy -= 10;
};

// Override parent method
Dog.prototype.eat = function (amount) {
  // Call parent method
  Animal.prototype.eat.call(this, amount);
  console.log(`${this.name} wags tail while eating`);
};

// Usage
const myDog = new Dog('Buddy', 'Golden Retriever');
myDog.eat(20); // "Buddy is eating" "Buddy wags tail while eating"
myDog.bark(); // "Buddy says: Woof!"
myDog.sleep(8); // "Buddy is sleeping"
console.log(myDog.energy); // 110

// Check inheritance
console.log(myDog instanceof Dog); // true
console.log(myDog instanceof Animal); // true

ES6 Classes and Prototypes

ES6 classes are syntactic sugar over prototypal inheritance.

// ES6 class syntax
class Vehicle {
  constructor(type, wheels) {
    this.type = type;
    this.wheels = wheels;
  }

  describe() {
    return `A ${this.type} with ${this.wheels} wheels`;
  }

  static compareWheels(v1, v2) {
    return v1.wheels - v2.wheels;
  }
}

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

  describe() {
    return `${super.describe()}: ${this.make} ${this.model}`;
  }

  honk() {
    return 'Beep beep!';
  }
}

// Under the hood, it's still prototypes
const myCar = new Car('Toyota', 'Camry');
console.log(myCar.describe()); // "A car with 4 wheels: Toyota Camry"

// Check prototypes
console.log(Object.getPrototypeOf(myCar) === Car.prototype); // true
console.log(Object.getPrototypeOf(Car.prototype) === Vehicle.prototype); // true

// Static methods are on the constructor
console.log(Vehicle.compareWheels(myCar, new Vehicle('bike', 2))); // 2

Advanced Prototype Patterns

Mixin Pattern

// Mixins for multiple inheritance behavior
const CanFly = {
  fly() {
    console.log(`${this.name} is flying at ${this.altitude}m`);
  },
  land() {
    this.altitude = 0;
    console.log(`${this.name} has landed`);
  },
};

const CanSwim = {
  swim() {
    console.log(`${this.name} is swimming`);
  },
  dive(depth) {
    console.log(`${this.name} dives to ${depth}m`);
  },
};

// Base class
class Animal {
  constructor(name) {
    this.name = name;
    this.altitude = 0;
  }
}

// Mix in capabilities
class Duck extends Animal {
  constructor(name) {
    super(name);
  }
}

// Apply mixins
Object.assign(Duck.prototype, CanFly, CanSwim);

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

Factory Functions with Prototypes

// Factory function with shared methods
function createPerson(name, age) {
  const person = Object.create(createPerson.prototype);
  person.name = name;
  person.age = age;
  return person;
}

createPerson.prototype = {
  greet() {
    return `Hi, I'm ${this.name}`;
  },
  haveBirthday() {
    this.age++;
    return `Now I'm ${this.age}`;
  },
};

const person1 = createPerson('Alice', 30);
console.log(person1.greet()); // "Hi, I'm Alice"

// Composition over inheritance
function withTimestamp(obj) {
  return Object.assign(Object.create(Object.getPrototypeOf(obj)), obj, {
    timestamp: Date.now(),
    getAge() {
      return Date.now() - this.timestamp;
    },
  });
}

const timedPerson = withTimestamp(person1);

Property Descriptors and Prototypes

// Define properties with specific behaviors
function BankAccount(balance) {
  let _balance = balance;

  Object.defineProperty(this, 'balance', {
    get() {
      return _balance;
    },
    set(value) {
      if (value < 0) {
        throw new Error('Balance cannot be negative');
      }
      _balance = value;
    },
    enumerable: true,
    configurable: false,
  });
}

BankAccount.prototype.deposit = function (amount) {
  if (amount > 0) {
    this.balance += amount;
  }
};

const account = new BankAccount(1000);
console.log(account.balance); // 1000
account.deposit(500);
console.log(account.balance); // 1500
// account.balance = -100; // Error!

// Prevent prototype pollution
Object.freeze(BankAccount.prototype);
// BankAccount.prototype.hack = function() {}; // Error in strict mode

Performance Considerations

// Inefficient: Methods created for each instance
function InefficientPerson(name) {
  this.name = name;
  this.greet = function () {
    // New function for each instance!
    return `Hello, I'm ${this.name}`;
  };
}

// Efficient: Methods shared via prototype
function EfficientPerson(name) {
  this.name = name;
}

EfficientPerson.prototype.greet = function () {
  return `Hello, I'm ${this.name}`;
};

// Memory comparison
const people1 = Array(10000)
  .fill(null)
  .map((_, i) => new InefficientPerson(`Person${i}`));

const people2 = Array(10000)
  .fill(null)
  .map((_, i) => new EfficientPerson(`Person${i}`));

// people1 has 10,000 greet functions
// people2 has 1 shared greet function

Common Pitfalls and Solutions

Accidental Prototype Modification

// Problem: Modifying shared references
function Collection() {}
Collection.prototype.items = []; // Shared array!

const c1 = new Collection();
const c2 = new Collection();

c1.items.push('item1');
console.log(c2.items); // ['item1'] - Oops!

// Solution: Initialize in constructor
function FixedCollection() {
  this.items = []; // Each instance gets its own array
}

Lost Prototype Chain

// Problem: Overwriting prototype loses methods
function Original() {}
Original.prototype.method1 = function () {};
Original.prototype.method2 = function () {};

// This overwrites everything
Original.prototype = {
  method3: function () {},
};
// method1 and method2 are lost!

// Solution: Add to existing prototype
Original.prototype.method3 = function () {};

// Or use Object.assign
Object.assign(Original.prototype, {
  method3: function () {},
  method4: function () {},
});

Forgetting new Keyword

function Person(name) {
  // Guard against forgetting 'new'
  if (!(this instanceof Person)) {
    return new Person(name);
  }
  this.name = name;
}

// Works with or without 'new'
const p1 = new Person('John');
const p2 = Person('Jane'); // Still works!

Modern Patterns and Best Practices

Object.setPrototypeOf vs Object.create

// Object.create - preferred for new objects
const proto = {
  greet() {
    return `Hello, ${this.name}`;
  },
};
const obj1 = Object.create(proto);
obj1.name = 'Object 1';

// Object.setPrototypeOf - can change existing prototype
const obj2 = { name: 'Object 2' };
Object.setPrototypeOf(obj2, proto);

// Performance: Object.create is generally faster
// Object.setPrototypeOf can deoptimize objects

Private Properties with WeakMaps

// True privacy with WeakMaps
const privateData = new WeakMap();

class SecureAccount {
  constructor(balance) {
    privateData.set(this, { balance });
  }

  get balance() {
    return privateData.get(this).balance;
  }

  deposit(amount) {
    const data = privateData.get(this);
    data.balance += amount;
  }
}

const account = new SecureAccount(1000);
console.log(account.balance); // 1000
console.log(account._balance); // undefined (truly private)

Debugging Prototypes

// Useful prototype debugging utilities
function inspectPrototypeChain(obj) {
  const chain = [];
  let current = obj;

  while (current) {
    chain.push({
      constructor: current.constructor?.name || 'Object',
      properties: Object.getOwnPropertyNames(current),
      prototype: current,
    });
    current = Object.getPrototypeOf(current);
  }

  return chain;
}

// Check if property is inherited
function isInherited(obj, prop) {
  return prop in obj && !obj.hasOwnProperty(prop);
}

// Get all properties including inherited
function getAllProperties(obj) {
  const props = new Set();
  let current = obj;

  while (current) {
    Object.getOwnPropertyNames(current).forEach((prop) => props.add(prop));
    current = Object.getPrototypeOf(current);
  }

  return Array.from(props);
}

Conclusion

JavaScript's prototypal inheritance is a powerful and flexible system:

  • Objects inherit directly from other objects
  • Prototype chain enables property and method lookup
  • Constructor functions and ES6 classes provide familiar patterns
  • Object.create() offers direct prototype manipulation
  • Performance benefits from shared methods

Key takeaways:

  • Every object has a prototype (except Object.prototype)
  • Properties are looked up through the prototype chain
  • Methods should be added to prototypes for efficiency
  • ES6 classes are syntactic sugar over prototypes
  • Understanding prototypes is crucial for JavaScript mastery

Master prototypes to unlock the full power of JavaScript's object system!