Inheritance in TypeScript

Before diving into the class syntax introduced in ES6, we must first revisit the native features of JavaScript to understand the essence of "inheritance." Unlike C++ or Java, which feature explicit inheritance relationships between classes and objects (i.e., direct parent/class concepts), JavaScript achieves code reuse and object extension through the Prototype Chain mechanism.

Every function in native JS has an internal prototype property pointing to a plain object; every instance object also possesses an internal __proto__ property that directly points to its constructor's prototype. This structure forms a linked list, known as the prototype chain. When accessing a property value at runtime, if the current object's definition does not contain that property, the JavaScript engine automatically traverses upward along the prototype chain until it finds the property or reaches the end of the chain (null).
The key to understanding inheritance lies in distinguishing two easily confused concepts:

  1. prototype: This is an object bound to a constructor, specifically designed to store methods shared by instances of that class. It is a static property that can be modified.
  2. __proto__ (or Object.getPrototypeOf()): This is the internal pointer within an instance object pointing to its constructor's prototype property. It is dynamic and represents a link relationship determined at runtime.

When we say "A inherits from B" in native JS, it actually means that A's constructor is set as a sub-prototype of B's prototype object, thereby establishing a prototype chain path from A -> B.

Relationship Between ES6 Class Syntax and Prototype Mechanism

ES6 (ECMAScript 2015) introduced the class and extends keywords, significantly simplifying the syntax of inheritance to align better with Object-Oriented Programming thinking. However, it is crucial to clarify: class is not the only way to inherit in JavaScript; it is merely a syntactic sugar encapsulation of the native prototype chain mechanism.

When defining code using ES6 Class, the logic still follows the prototype's operational flow. For example, after calling the parent class constructor (via super()) within extends B and executing instance methods, the underlying mechanism still relies on prototype lookup and linking to ensure the correctness of method sharing and call chains.

Manual Inheritance Implementation Example

If we do not use ES6 Class, we can achieve basic single inheritance by combining function expressions with prototype assignment:

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

Animal.prototype.sayHello = function() {
    console.log(`${this.name} says hello.`);
};

// Create the Dog class and set its prototype to point to an instance of Animal
function Dog(breed, owner) {
    // Must call the parent constructor to initialize 'this', otherwise properties in the prototype chain cannot be bound correctly
    Animal.call(this, breed + ' dog'); 
}

Dog.prototype = new Animal();
// Fix reference issue: Re-point Dog's prototype to the newly created instance (since a new Animal instance was created above)
delete Dog.prototype.name; 

Dog.prototype.bark = function() {
    console.log(`${this.name} barks!`);
};

const myDog = new Dog('Golden Retriever', 'Alice');
myDog.sayHello(); // Output: Golden Retriever dog says hello. (Found via prototype chain)

Practical Application and Advantages of ES6 Class Inheritance

In actual development, using class and extends directly is the preferred solution. It not only offers higher code readability but also automatically handles constructor invocation, property access, and some modern features (such as static methods).

Polymorphism and Override Mechanism

ES6 Class supports method overriding, which is one of the core values of inheritance. Subclasses can define methods with the same name as those in the parent class to override their implementation logic, thereby achieving polymorphism. For example:

class Bird {
    fly() {
        console.log('Bird is flying in the sky.');
    }
}

class Eagle extends Bird {
    // Override parent.fly method
    fly() {
        super.fly(); 
        console.log('Eagle soars high above clouds.');
    }
}

const eagle = new Eagle();
eagle.fly(); 
// Output:
// Bird is flying in the sky.
// Eagle soars high above clouds.

Here, super.fly() ensures that the parent class method still executes when called on a subclass instance, demonstrating the completeness of the inheritance chain.

Sharing of Static Properties and Methods

In addition to instance-level inheritance, ES6 Class provides the static keyword to define properties or methods belonging to the class itself rather than its instances. This is very useful for utility functions, constant management, and metaprogramming scenarios:

class Calculator {
    static PI = 3.14159; // Static property
    
    add(a, b) {
        return a + b;
    }
}

// Can be accessed directly without instantiating an object
console.log(Calculator.PI); 
const result = Calculator.add(2, 3);

Limitations of Inheritance and Consideration of Alternative Solutions

Although ES6 Class provides powerful capabilities, it may appear overly complex or insufficiently flexible in certain scenarios. For instance, when deep customization of the prototype chain is required, handling complex mixin patterns, or performing advanced closure operations, the native JS prototype mechanism often offers more granular control. Furthermore, in specific framework design patterns, developers might opt for compositional inheritance instead of traditional single or multiple inheritance to avoid the complexity brought about by the "Diamond Problem."

Summary

Whether using native function prototype assignment or modern ES6 Class syntax, the core logic revolves around the object's prototype chain. Mastering this underlying mechanism not only allows you to understand JavaScript's runtime principles more thoroughly but also enables you to make more flexible and efficient decisions when facing complex architectural designs.