Object Prototypes and Inheritance

Understanding JavaScript's Prototype-Based Inheritance System

What is a Prototype?

In JavaScript, every object has a hidden link to another object called its prototype. When you try to access a property that doesn't exist on an object, JavaScript automatically looks for it in the object's prototype, and if not found there, in the prototype's prototype, continuing up the chain until it reaches the end (typically Object.prototype).

Think of prototypes like genetic inheritance in living organisms. Just as you inherit traits from your ancestors, JavaScript objects inherit properties and methods from their prototype ancestors.

graph TD A[Object You Created] -->|inherits from| B[Object's Prototype] B -->|inherits from| C[Prototype's Prototype] C -->|inherits from| D[Object.prototype] D -->|ends at| E[null] style A fill:#e1f5fe,stroke:#0288d1 style B fill:#e8f5e9,stroke:#388e3c style C fill:#fff8e1,stroke:#ffa000 style D fill:#f3e5f5,stroke:#7b1fa2 style E fill:#ffebee,stroke:#c62828

Visualizing the Prototype Chain

myObject name: "John" age: 30 Constructor.prototype greet(): function species: "human" Object.prototype toString(): function hasOwnProperty(): function __proto__ __proto__

How Prototypes Work

Every JavaScript object has an internal property called [[Prototype]] (accessible via __proto__ in browsers, though this is deprecated). This property refers to the object's prototype.

// Creating an object
const person = {
    firstName: "John",
    lastName: "Doe",
    getFullName: function() {
        return this.firstName + " " + this.lastName;
    }
};

// Creating another object that inherits from person
const employee = {
    jobTitle: "Developer",
    employeeId: "EMP123"
};

// Set person as the prototype of employee
Object.setPrototypeOf(employee, person);

// Now employee inherits properties and methods from person
console.log(employee.firstName);       // "John"
console.log(employee.getFullName());   // "John Doe"
console.log(employee.jobTitle);        // "Developer"

// Check if a property exists directly on the object
console.log(employee.hasOwnProperty('jobTitle'));      // true
console.log(employee.hasOwnProperty('firstName'));     // false (inherited)

Property Lookup Process

When you access a property on an object, JavaScript follows these steps:

  1. Check if the property exists on the object itself
  2. If not, check the object's prototype
  3. If still not found, check the prototype's prototype
  4. Continue up the prototype chain until the property is found or until Object.prototype is reached
  5. If the property is not found anywhere in the chain, return undefined
// Property lookup visualization
console.log(employee.toString());  // "[object Object]"

// Here's what happens:
// 1. JavaScript looks for toString on the employee object - not found
// 2. Looks for toString on employee's prototype (person) - not found
// 3. Looks for toString on person's prototype (Object.prototype) - FOUND!
// 4. The method is called with 'employee' as 'this'

Constructor Functions and Prototypes

When you use constructor functions with the new keyword, JavaScript automatically creates a new object and links it to the constructor's prototype property.

// Constructor function
function Person(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
}

// Adding a method to the prototype
Person.prototype.getFullName = function() {
    return this.firstName + " " + this.lastName;
};

// Creating instances
const person1 = new Person("John", "Doe");
const person2 = new Person("Jane", "Smith");

console.log(person1.getFullName());  // "John Doe"
console.log(person2.getFullName());  // "Jane Smith"

// Both instances share the same method
console.log(person1.getFullName === person2.getFullName);  // true

The power of prototypes is memory efficiency - the method is defined only once in memory (on the prototype) but can be used by all instances.

The Benefits of Prototype Methods

graph TD A[Constructor Method] --> B[Creates New Copy for Each Instance] C[Prototype Method] --> D[Shared by All Instances] style A fill:#ffcdd2,stroke:#c62828 style B fill:#ffcdd2,stroke:#c62828 style C fill:#c8e6c9,stroke:#2e7d32 style D fill:#c8e6c9,stroke:#2e7d32
// Methods defined in the constructor (less efficient)
function Car1(make, model) {
    this.make = make;
    this.model = model;
    
    // Each instance gets its own copy of this method
    this.getInfo = function() {
        return `${this.make} ${this.model}`;
    };
}

// Methods defined on the prototype (more efficient)
function Car2(make, model) {
    this.make = make;
    this.model = model;
}

// All instances share one copy of this method
Car2.prototype.getInfo = function() {
    return `${this.make} ${this.model}`;
};

// Memory usage comparison
const car1a = new Car1("Toyota", "Corolla");
const car1b = new Car1("Honda", "Civic");
const car2a = new Car2("Toyota", "Corolla");
const car2b = new Car2("Honda", "Civic");

console.log(car1a.getInfo === car1b.getInfo);  // false (different function objects)
console.log(car2a.getInfo === car2b.getInfo);  // true (same function object)

Prototype Inheritance

JavaScript's inheritance is prototype-based, not class-based. Objects inherit directly from other objects.

Creating Inheritance Chains

// Parent constructor
function Animal(name) {
    this.name = name;
    this.isAlive = true;
}

// Parent methods
Animal.prototype.eat = function(food) {
    console.log(`${this.name} is eating ${food}`);
};

Animal.prototype.sleep = function() {
    console.log(`${this.name} is sleeping`);
};

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

// Set up inheritance
// This creates the prototype chain: Dog -> Animal -> Object
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog; // Fix the constructor property

// Add methods specific to Dog
Dog.prototype.bark = function() {
    console.log(`${this.name} says woof!`);
};

Dog.prototype.fetch = function(item) {
    console.log(`${this.name} fetches the ${item}`);
};

// Create a dog instance
const rex = new Dog("Rex", "German Shepherd");

rex.eat("dog food");  // "Rex is eating dog food" (inherited method)
rex.sleep();          // "Rex is sleeping" (inherited method)
rex.bark();           // "Rex says woof!" (own method)

// Verify the prototype chain
console.log(rex instanceof Dog);     // true
console.log(rex instanceof Animal);  // true
console.log(rex instanceof Object);  // true

This pattern is how JavaScript implemented inheritance before ES6 classes.

ES6 Classes and Inheritance

ES6 introduced the class syntax, which is syntactic sugar over the prototype-based inheritance system. Under the hood, it still uses prototypes.

// Parent class
class Animal {
    constructor(name) {
        this.name = name;
        this.isAlive = true;
    }
    
    eat(food) {
        console.log(`${this.name} is eating ${food}`);
    }
    
    sleep() {
        console.log(`${this.name} is sleeping`);
    }
}

// Child class extending the parent
class Dog extends Animal {
    constructor(name, breed) {
        super(name); // Call parent constructor
        this.breed = breed;
    }
    
    bark() {
        console.log(`${this.name} says woof!`);
    }
    
    fetch(item) {
        console.log(`${this.name} fetches the ${item}`);
    }
}

// Create a dog instance
const buddy = new Dog("Buddy", "Golden Retriever");

buddy.eat("treats");  // "Buddy is eating treats" (inherited method)
buddy.bark();         // "Buddy says woof!" (own method)

// This is still prototype-based inheritance behind the scenes
console.log(Object.getPrototypeOf(buddy) === Dog.prototype);  // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype);  // true

The ES6 class syntax makes inheritance patterns more readable and familiar to developers coming from class-based languages, while still leveraging JavaScript's prototype system.

Object.create() Pattern

Another way to create objects with specific prototypes is using Object.create(), which allows direct prototype assignment without constructors.

// Create a base object to serve as a prototype
const vehiclePrototype = {
    init: function(make, model) {
        this.make = make;
        this.model = model;
        return this;
    },
    getInfo: function() {
        return `${this.make} ${this.model}`;
    },
    start: function() {
        console.log("Engine started");
    },
    stop: function() {
        console.log("Engine stopped");
    }
};

// Create a car object that inherits from vehiclePrototype
const car = Object.create(vehiclePrototype);
car.init("Toyota", "Camry");
car.drive = function() {
    console.log("Car is driving");
};

console.log(car.getInfo());  // "Toyota Camry"
car.start();                 // "Engine started"
car.drive();                 // "Car is driving"

// Create a motorcycle that also inherits from vehiclePrototype
const motorcycle = Object.create(vehiclePrototype);
motorcycle.init("Harley", "Davidson");
motorcycle.wheelie = function() {
    console.log("Doing a wheelie!");
};

motorcycle.start();  // "Engine started"
motorcycle.wheelie();  // "Doing a wheelie!"

This approach emphasizes behavior delegation rather than type inheritance, which many consider more aligned with JavaScript's design philosophy.

Property Shadowing

When an object has a property with the same name as a property in its prototype chain, the object's own property "shadows" the prototype property.

function Vehicle() {
    this.speed = 0;
}

Vehicle.prototype.accelerate = function() {
    this.speed += 10;
    console.log(`Speed: ${this.speed}`);
};

function SportsCar() {
    Vehicle.call(this);
}

SportsCar.prototype = Object.create(Vehicle.prototype);
SportsCar.prototype.constructor = SportsCar;

// Override the accelerate method
SportsCar.prototype.accelerate = function() {
    // This shadows the Vehicle.prototype.accelerate method
    this.speed += 20; // Sports cars accelerate faster
    console.log(`ZOOM! Speed: ${this.speed}`);
};

const car = new Vehicle();
const ferrari = new SportsCar();

car.accelerate();      // "Speed: 10"
ferrari.accelerate();  // "ZOOM! Speed: 20"

// We can still access the parent method using call or apply
Vehicle.prototype.accelerate.call(ferrari);  // "Speed: 30"

Property shadowing allows for method overriding, a key concept in object-oriented programming.

Working with Prototypes Correctly

Best Practices

Common Pitfalls

// BAD PRACTICE: Modifying built-in prototypes
Array.prototype.first = function() {
    return this[0];
};

// This could break code that loops through arrays
for (let prop in [1, 2, 3]) {
    console.log(prop); // "0", "1", "2", "first" - Oops!
}

// BETTER PRACTICE: Create utility functions or extend only your objects
function getFirst(array) {
    return array[0];
}

// Or create subclasses if using ES6
class MyArray extends Array {
    first() {
        return this[0];
    }
}

Real-World Applications

Building a UI Component System

// Base Component
class UIComponent {
    constructor(id) {
        this.id = id;
        this.element = null;
    }
    
    render() {
        throw new Error("Render method must be implemented by subclasses");
    }
    
    mount(container) {
        if (!this.element) {
            this.render();
        }
        container.appendChild(this.element);
        this.onMount();
    }
    
    onMount() {
        // Hook for subclasses
    }
    
    unmount() {
        if (this.element && this.element.parentNode) {
            this.element.parentNode.removeChild(this.element);
            this.onUnmount();
        }
    }
    
    onUnmount() {
        // Hook for subclasses
    }
}

// Button Component
class Button extends UIComponent {
    constructor(id, text, onClick) {
        super(id);
        this.text = text;
        this.onClick = onClick;
    }
    
    render() {
        this.element = document.createElement('button');
        this.element.id = this.id;
        this.element.textContent = this.text;
        this.element.addEventListener('click', this.onClick);
        return this.element;
    }
    
    setText(newText) {
        this.text = newText;
        if (this.element) {
            this.element.textContent = newText;
        }
    }
    
    disable() {
        if (this.element) {
            this.element.disabled = true;
        }
    }
    
    enable() {
        if (this.element) {
            this.element.disabled = false;
        }
    }
}

// Form Input Component
class Input extends UIComponent {
    constructor(id, type, placeholder, value = '') {
        super(id);
        this.type = type;
        this.placeholder = placeholder;
        this.value = value;
    }
    
    render() {
        this.element = document.createElement('input');
        this.element.id = this.id;
        this.element.type = this.type;
        this.element.placeholder = this.placeholder;
        this.element.value = this.value;
        this.element.addEventListener('input', (e) => {
            this.value = e.target.value;
        });
        return this.element;
    }
    
    getValue() {
        return this.value;
    }
    
    setValue(newValue) {
        this.value = newValue;
        if (this.element) {
            this.element.value = newValue;
        }
    }
}

// Usage example
const loginButton = new Button('login-btn', 'Log In', () => {
    console.log('Login clicked');
    if (usernameInput.getValue() && passwordInput.getValue()) {
        // Perform login
        loginButton.setText('Logging in...');
        loginButton.disable();
    }
});

const usernameInput = new Input('username', 'text', 'Username');
const passwordInput = new Input('password', 'password', 'Password');

// Mount components to DOM
document.addEventListener('DOMContentLoaded', () => {
    const container = document.getElementById('app');
    usernameInput.mount(container);
    passwordInput.mount(container);
    loginButton.mount(container);
});

Game Entity System

// Base Game Entity
class Entity {
    constructor(x, y) {
        this.x = x;
        this.y = y;
        this.width = 0;
        this.height = 0;
        this.speed = 0;
        this.active = true;
    }
    
    update(deltaTime) {
        // Base update logic
    }
    
    render(context) {
        // Base rendering logic
    }
    
    getBounds() {
        return {
            left: this.x,
            top: this.y,
            right: this.x + this.width,
            bottom: this.y + this.height
        };
    }
    
    collidesWith(entity) {
        const a = this.getBounds();
        const b = entity.getBounds();
        
        return a.left < b.right && 
               a.right > b.left && 
               a.top < b.bottom && 
               a.bottom > b.top;
    }
}

// Player Character
class Player extends Entity {
    constructor(x, y) {
        super(x, y);
        this.width = 32;
        this.height = 48;
        this.speed = 200;
        this.health = 100;
        this.isJumping = false;
        this.jumpVelocity = 0;
    }
    
    update(deltaTime) {
        super.update(deltaTime);
        
        // Player-specific update logic
        if (this.isJumping) {
            this.y += this.jumpVelocity * deltaTime;
            this.jumpVelocity += 980 * deltaTime; // Gravity
            
            if (this.y > 400) { // Ground level
                this.y = 400;
                this.isJumping = false;
            }
        }
    }
    
    render(context) {
        context.fillStyle = 'blue';
        context.fillRect(this.x, this.y, this.width, this.height);
    }
    
    jump() {
        if (!this.isJumping) {
            this.isJumping = true;
            this.jumpVelocity = -500;
        }
    }
    
    takeDamage(amount) {
        this.health -= amount;
        if (this.health <= 0) {
            this.health = 0;
            this.active = false;
        }
    }
}

// Enemy Character
class Enemy extends Entity {
    constructor(x, y, type) {
        super(x, y);
        this.type = type;
        this.width = 32;
        this.height = 32;
        
        if (type === 'basic') {
            this.speed = 100;
            this.damage = 10;
        } else if (type === 'advanced') {
            this.speed = 150;
            this.damage = 20;
        }
    }
    
    update(deltaTime) {
        super.update(deltaTime);
        
        // Move toward the player
        this.x -= this.speed * deltaTime;
        
        // Remove if off screen
        if (this.x < -this.width) {
            this.active = false;
        }
    }
    
    render(context) {
        context.fillStyle = this.type === 'basic' ? 'red' : 'darkred';
        context.fillRect(this.x, this.y, this.width, this.height);
    }
    
    attack(player) {
        if (this.collidesWith(player)) {
            player.takeDamage(this.damage);
            this.active = false; // Enemy disappears after attacking
        }
    }
}

Practice Activities

Activity 1: Create a Shape Hierarchy

Implement a Shape base class and create subclasses for Circle, Rectangle, and Triangle. Add methods to calculate area and perimeter for each shape.

Activity 2: Build a Vehicle Inheritance System

Create a hierarchical structure for vehicles, starting with a base Vehicle class, then Car and Motorcycle subclasses, and finally specific models like Sedan, SUV, SportBike, etc.

Activity 3: Implement Prototype Methods for Arrays

Create your own MyArray constructor that inherits from Array but has additional methods like sum(), average(), and max(). Do this without modifying the built-in Array prototype.

Summary

In this lecture, we've explored:

Understanding JavaScript's prototype system is essential for mastering object-oriented programming in the language. Even with the introduction of ES6 classes, the prototype chain is still the underlying mechanism for inheritance.