You are viewing a preview of this lesson. Sign in to start learning
Back to TypeScript

Inheritance & Polymorphism

Extend classes to inherit functionality and override methods for specialized behavior in derived classes.

Inheritance & Polymorphism in TypeScript

Master inheritance and polymorphism in TypeScript with free flashcards and spaced repetition practice. This lesson covers class hierarchies, method overriding, abstract classes, interfaces, and runtime polymorphismβ€”essential concepts for building scalable object-oriented applications.

Welcome to Object-Oriented TypeScript πŸ’»

Inheritance and polymorphism are two of the most powerful pillars of object-oriented programming. Inheritance allows you to create new classes based on existing ones, promoting code reuse and establishing "is-a" relationships. Polymorphism enables objects of different types to be treated uniformly through a common interface, while still maintaining their unique behaviors. Together, these concepts form the backbone of flexible, maintainable codebases.

In this lesson, you'll learn how to leverage TypeScript's class system to build hierarchical relationships, override methods effectively, work with abstract classes and interfaces, and harness the power of runtime polymorphism. By the end, you'll be able to design elegant class structures that scale with your application's complexity.

Core Concepts 🎯

Understanding Inheritance πŸ”Ί

Inheritance is the mechanism by which one class (the child or derived class) acquires properties and methods from another class (the parent or base class). This creates a hierarchical relationship where specialized classes extend more general ones.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚      Animal (Base)          β”‚
β”‚  + name: string             β”‚
β”‚  + move(): void             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β”‚ extends
           β”‚
     β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”
     β”‚            β”‚
β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
β”‚   Dog    β”‚ β”‚   Bird   β”‚
β”‚ + bark() β”‚ β”‚ + fly()  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key benefits of inheritance:

  • Code reuse: Common functionality lives in the base class
  • Logical organization: Related classes form natural hierarchies
  • Polymorphism: Derived classes can be used wherever base classes are expected
  • Maintainability: Changes to shared behavior only need to happen once

πŸ’‘ Tip: Use inheritance when there's a clear "is-a" relationship. A Dog is an Animal, a Manager is an Employee.

The extends Keyword πŸ”—

TypeScript uses the extends keyword to establish inheritance:

class Animal {
  protected name: string;
  
  constructor(name: string) {
    this.name = name;
  }
  
  move(distance: number = 0): void {
    console.log(`${this.name} moved ${distance} meters.`);
  }
}

class Dog extends Animal {
  bark(): void {
    console.log(`${this.name} barks: Woof! Woof!`);
  }
}

const dog = new Dog("Buddy");
dog.move(10);  // Inherited method
dog.bark();    // Own method

Key points:

  • The child class inherits all properties and methods from the parent
  • Use protected for members that should be accessible to child classes
  • Use private for members that should only be accessible within the class itself
  • Use public (default) for members accessible anywhere

The super Keyword 🎯

The super keyword serves two crucial purposes:

  1. Calling the parent constructor: Required when the child class has its own constructor
  2. Accessing parent methods: Useful when overriding methods but still needing parent functionality
class Animal {
  constructor(protected name: string) {}
  
  move(distance: number = 0): void {
    console.log(`${this.name} moved ${distance} meters.`);
  }
}

class Horse extends Animal {
  constructor(name: string, private speed: number) {
    super(name);  // MUST call parent constructor first!
  }
  
  move(distance: number = 45): void {
    console.log("Galloping...");
    super.move(distance);  // Call parent's move method
  }
}

⚠️ Common Mistake: Forgetting to call super() in a child class constructor will cause a compile error!

Method Overriding πŸ”„

Method overriding occurs when a child class provides its own implementation of a method inherited from the parent class. The method signature must match (same name and parameters).

class Shape {
  getArea(): number {
    return 0;  // Default implementation
  }
}

class Circle extends Shape {
  constructor(private radius: number) {
    super();
  }
  
  // Override parent's getArea method
  getArea(): number {
    return Math.PI * this.radius * this.radius;
  }
}

class Rectangle extends Shape {
  constructor(private width: number, private height: number) {
    super();
  }
  
  getArea(): number {
    return this.width * this.height;
  }
}

πŸ’‘ Best Practice: Use the override keyword (TypeScript 4.3+) to make your intention explicit:

class Circle extends Shape {
  override getArea(): number {
    return Math.PI * this.radius * this.radius;
  }
}

This helps catch errors if you accidentally misspell the method name or change the parent's signature.

Understanding Polymorphism 🎭

Polymorphism (Greek: "many forms") is the ability of objects of different types to be treated as instances of a common parent type. At runtime, the correct method implementation is called based on the object's actual type.

function calculateAreas(shapes: Shape[]): void {
  shapes.forEach(shape => {
    // Polymorphism in action!
    // Calls Circle.getArea() or Rectangle.getArea()
    // depending on the actual object type
    console.log(`Area: ${shape.getArea()}`);
  });
}

const shapes: Shape[] = [
  new Circle(5),
  new Rectangle(4, 6),
  new Circle(3)
];

calculateAreas(shapes);

Real-world analogy 🌍: Think of a remote control (interface). You can control different devices (TV, stereo, AC) with the same "power" button, but each device responds differently. That's polymorphism!

🧠 Mnemonic: "POLY-MORPH"

Parent type reference
Object decides behavior
Late binding (runtime)
Yields different results

Many forms
One interface
Runtime resolution
Powerful abstraction
Hierarchy enabled

Abstract Classes πŸ›οΈ

An abstract class is a base class that cannot be instantiated directly. It may contain abstract methods (methods without implementation) that child classes must implement.

abstract class Shape {
  constructor(protected color: string) {}
  
  // Abstract method - no implementation
  abstract getArea(): number;
  abstract getPerimeter(): number;
  
  // Concrete method - has implementation
  describe(): void {
    console.log(`A ${this.color} shape with area ${this.getArea()}`);
  }
}

class Circle extends Shape {
  constructor(color: string, private radius: number) {
    super(color);
  }
  
  // MUST implement abstract methods
  getArea(): number {
    return Math.PI * this.radius * this.radius;
  }
  
  getPerimeter(): number {
    return 2 * Math.PI * this.radius;
  }
}

// ❌ Error: Cannot create an instance of an abstract class
// const shape = new Shape("red");

// βœ… This works
const circle = new Circle("blue", 5);
circle.describe();  // Inherited concrete method

When to use abstract classes:

  • You want to provide shared implementation alongside abstract methods
  • You need to define fields or constructors
  • You're modeling a clear hierarchy with shared state

Interfaces vs Abstract Classes πŸ†š

TypeScript offers both interfaces and abstract classes for defining contracts. Here's when to use each:

Feature Interface Abstract Class
Multiple inheritance βœ… Can implement many ❌ Can extend only one
Implementation ❌ No method bodies βœ… Can have concrete methods
Fields/State ❌ No instance fields βœ… Can have fields
Constructor ❌ No constructor βœ… Can have constructor
Access modifiers ❌ All public βœ… public/protected/private
Runtime presence ❌ Erased after compilation βœ… Exists at runtime
// Interface approach - pure contract
interface Drawable {
  draw(): void;
  getColor(): string;
}

interface Resizable {
  resize(scale: number): void;
}

// Can implement multiple interfaces!
class Canvas implements Drawable, Resizable {
  constructor(private color: string) {}
  
  draw(): void {
    console.log("Drawing canvas");
  }
  
  getColor(): string {
    return this.color;
  }
  
  resize(scale: number): void {
    console.log(`Resizing by ${scale}x`);
  }
}

πŸ’‘ Rule of thumb: Use interfaces for "can-do" relationships (capabilities) and abstract classes for "is-a" relationships (hierarchies).

Type Casting and Type Guards πŸ›‘οΈ

When working with polymorphic code, you sometimes need to check or assert the specific type of an object:

class Animal {
  constructor(public name: string) {}
}

class Dog extends Animal {
  bark(): void {
    console.log("Woof!");
  }
}

class Cat extends Animal {
  meow(): void {
    console.log("Meow!");
  }
}

function makeSound(animal: Animal): void {
  // Type guard using instanceof
  if (animal instanceof Dog) {
    animal.bark();  // TypeScript knows it's a Dog here
  } else if (animal instanceof Cat) {
    animal.meow();  // TypeScript knows it's a Cat here
  }
}

const pets: Animal[] = [new Dog("Buddy"), new Cat("Whiskers")];
pets.forEach(makeSound);

Type assertion (use sparingly!):

const animal: Animal = new Dog("Max");

// Type assertion - you're telling TypeScript "trust me"
(animal as Dog).bark();

⚠️ Warning: Type assertions bypass TypeScript's type checking. Use type guards (instanceof, custom type predicates) when possible!

Practical Examples πŸ”§

Example 1: Employee Management System πŸ‘”

Let's build a realistic employee hierarchy demonstrating inheritance and polymorphism:

abstract class Employee {
  constructor(
    protected name: string,
    protected id: number,
    protected baseSalary: number
  ) {}
  
  // Abstract methods each employee type must implement
  abstract calculateSalary(): number;
  abstract getRole(): string;
  
  // Concrete method shared by all employees
  getDetails(): string {
    return `${this.name} (ID: ${this.id}) - ${this.getRole()}`;
  }
  
  protected getBaseSalary(): number {
    return this.baseSalary;
  }
}

class Developer extends Employee {
  constructor(
    name: string,
    id: number,
    baseSalary: number,
    private programmingLanguages: string[]
  ) {
    super(name, id, baseSalary);
  }
  
  calculateSalary(): number {
    // Developers get bonus for each language
    const languageBonus = this.programmingLanguages.length * 5000;
    return this.getBaseSalary() + languageBonus;
  }
  
  getRole(): string {
    return "Software Developer";
  }
  
  getLanguages(): string {
    return this.programmingLanguages.join(", ");
  }
}

class Manager extends Employee {
  constructor(
    name: string,
    id: number,
    baseSalary: number,
    private teamSize: number
  ) {
    super(name, id, baseSalary);
  }
  
  calculateSalary(): number {
    // Managers get bonus based on team size
    const teamBonus = this.teamSize * 3000;
    return this.getBaseSalary() + teamBonus;
  }
  
  getRole(): string {
    return "Manager";
  }
  
  getTeamSize(): number {
    return this.teamSize;
  }
}

class Intern extends Employee {
  constructor(
    name: string,
    id: number,
    baseSalary: number,
    private university: string
  ) {
    super(name, id, baseSalary);
  }
  
  calculateSalary(): number {
    // Interns get base salary only (no bonus)
    return this.getBaseSalary();
  }
  
  getRole(): string {
    return `Intern from ${this.university}`;
  }
}

// Polymorphism in action!
function processPayroll(employees: Employee[]): void {
  let totalPayroll = 0;
  
  employees.forEach(employee => {
    const salary = employee.calculateSalary();
    totalPayroll += salary;
    console.log(`${employee.getDetails()}: $${salary}`);
  });
  
  console.log(`\nTotal Payroll: $${totalPayroll}`);
}

// Usage
const employees: Employee[] = [
  new Developer("Alice", 101, 80000, ["TypeScript", "Python", "Go"]),
  new Manager("Bob", 102, 90000, 5),
  new Intern("Charlie", 103, 30000, "MIT"),
  new Developer("Diana", 104, 85000, ["Java", "Kotlin"])
];

processPayroll(employees);

Output:

Alice (ID: 101) - Software Developer: $95000
Bob (ID: 102) - Manager: $105000
Charlie (ID: 103) - Intern from MIT: $30000
Diana (ID: 104) - Software Developer: $95000

Total Payroll: $325000

Key takeaways from this example:

  • The abstract Employee class defines the contract all employees must follow
  • Each subclass provides its own salary calculation logic
  • The processPayroll function works with any Employee type through polymorphism
  • Adding new employee types (e.g., Contractor) doesn't require changing existing code

Example 2: Shape Drawing Application 🎨

Let's create a graphics system showcasing interface implementation and polymorphism:

interface Drawable {
  draw(): void;
  getArea(): number;
}

interface Colorable {
  setColor(color: string): void;
  getColor(): string;
}

// Implementing multiple interfaces
abstract class Shape implements Drawable, Colorable {
  protected color: string = "black";
  
  abstract draw(): void;
  abstract getArea(): number;
  
  setColor(color: string): void {
    this.color = color;
  }
  
  getColor(): string {
    return this.color;
  }
}

class Circle extends Shape {
  constructor(private radius: number) {
    super();
  }
  
  draw(): void {
    console.log(`Drawing a ${this.color} circle with radius ${this.radius}`);
  }
  
  getArea(): number {
    return Math.PI * this.radius ** 2;
  }
}

class Rectangle extends Shape {
  constructor(private width: number, private height: number) {
    super();
  }
  
  draw(): void {
    console.log(`Drawing a ${this.color} rectangle ${this.width}x${this.height}`);
  }
  
  getArea(): number {
    return this.width * this.height;
  }
}

class Triangle extends Shape {
  constructor(private base: number, private height: number) {
    super();
  }
  
  draw(): void {
    console.log(`Drawing a ${this.color} triangle (base: ${this.base}, height: ${this.height})`);
  }
  
  getArea(): number {
    return (this.base * this.height) / 2;
  }
}

// Graphics engine using polymorphism
class Canvas {
  private shapes: Shape[] = [];
  
  addShape(shape: Shape): void {
    this.shapes.push(shape);
  }
  
  renderAll(): void {
    console.log("=== Rendering Canvas ===");
    this.shapes.forEach(shape => shape.draw());
  }
  
  getTotalArea(): number {
    return this.shapes.reduce((sum, shape) => sum + shape.getArea(), 0);
  }
}

// Usage
const canvas = new Canvas();

const circle = new Circle(5);
circle.setColor("red");

const rectangle = new Rectangle(10, 6);
rectangle.setColor("blue");

const triangle = new Triangle(8, 4);
triangle.setColor("green");

canvas.addShape(circle);
canvas.addShape(rectangle);
canvas.addShape(triangle);

canvas.renderAll();
console.log(`\nTotal area covered: ${canvas.getTotalArea().toFixed(2)} sq units`);

Why this design works:

  • Interfaces (Drawable, Colorable) define capabilities without implementation
  • Abstract Shape class provides common behavior while enforcing contracts
  • Concrete shapes focus on their specific drawing and calculation logic
  • The Canvas class treats all shapes uniformly through polymorphism

Example 3: Payment Processing System πŸ’³

A real-world example showing how inheritance and polymorphism handle different payment methods:

abstract class PaymentMethod {
  constructor(protected accountHolder: string) {}
  
  abstract processPayment(amount: number): boolean;
  abstract getPaymentType(): string;
  abstract validatePayment(): boolean;
  
  displayReceipt(amount: number, success: boolean): void {
    console.log(`\n--- Payment Receipt ---`);
    console.log(`Account Holder: ${this.accountHolder}`);
    console.log(`Payment Method: ${this.getPaymentType()}`);
    console.log(`Amount: $${amount.toFixed(2)}`);
    console.log(`Status: ${success ? "SUCCESS" : "FAILED"}`);
    console.log(`----------------------\n`);
  }
}

class CreditCard extends PaymentMethod {
  constructor(
    accountHolder: string,
    private cardNumber: string,
    private cvv: string,
    private expiryDate: string
  ) {
    super(accountHolder);
  }
  
  validatePayment(): boolean {
    // Simple validation (in reality, much more complex)
    return this.cardNumber.length === 16 && this.cvv.length === 3;
  }
  
  processPayment(amount: number): boolean {
    if (!this.validatePayment()) {
      console.log("Invalid credit card details!");
      return false;
    }
    
    console.log(`Processing credit card payment of $${amount}...`);
    // Simulate payment processing
    const success = Math.random() > 0.1; // 90% success rate
    this.displayReceipt(amount, success);
    return success;
  }
  
  getPaymentType(): string {
    return "Credit Card";
  }
}

class PayPal extends PaymentMethod {
  constructor(
    accountHolder: string,
    private email: string,
    private isVerified: boolean
  ) {
    super(accountHolder);
  }
  
  validatePayment(): boolean {
    return this.email.includes("@") && this.isVerified;
  }
  
  processPayment(amount: number): boolean {
    if (!this.validatePayment()) {
      console.log("PayPal account not verified!");
      return false;
    }
    
    console.log(`Processing PayPal payment of $${amount}...`);
    const success = Math.random() > 0.05; // 95% success rate
    this.displayReceipt(amount, success);
    return success;
  }
  
  getPaymentType(): string {
    return "PayPal";
  }
}

class BankTransfer extends PaymentMethod {
  constructor(
    accountHolder: string,
    private bankAccount: string,
    private routingNumber: string
  ) {
    super(accountHolder);
  }
  
  validatePayment(): boolean {
    return this.bankAccount.length >= 8 && this.routingNumber.length === 9;
  }
  
  processPayment(amount: number): boolean {
    if (!this.validatePayment()) {
      console.log("Invalid bank account details!");
      return false;
    }
    
    console.log(`Processing bank transfer of $${amount}...`);
    // Bank transfers are slower but more reliable
    const success = Math.random() > 0.02; // 98% success rate
    this.displayReceipt(amount, success);
    return success;
  }
  
  getPaymentType(): string {
    return "Bank Transfer";
  }
}

// Payment processor using polymorphism
class CheckoutSystem {
  processOrder(paymentMethod: PaymentMethod, amount: number): void {
    console.log(`\nInitiating payment using ${paymentMethod.getPaymentType()}...`);
    const result = paymentMethod.processPayment(amount);
    
    if (result) {
      console.log("βœ… Order completed successfully!");
    } else {
      console.log("❌ Payment failed. Please try another method.");
    }
  }
}

// Usage
const checkout = new CheckoutSystem();

const creditCard = new CreditCard("John Doe", "1234567812345678", "123", "12/25");
const paypal = new PayPal("jane@example.com", "jane@example.com", true);
const bank = new BankTransfer("Alice Smith", "12345678", "123456789");

checkout.processOrder(creditCard, 99.99);
checkout.processOrder(paypal, 149.50);
checkout.processOrder(bank, 500.00);

Benefits of this polymorphic design:

  • Adding new payment methods (cryptocurrency, gift cards) requires no changes to CheckoutSystem
  • Each payment method encapsulates its own validation and processing logic
  • The system treats all payment methods uniformly through the PaymentMethod interface
  • Easy to test individual payment methods in isolation

Example 4: Notification System πŸ“§

A practical example showing how polymorphism simplifies handling multiple notification channels:

interface NotificationChannel {
  send(recipient: string, message: string): Promise<boolean>;
  getChannelName(): string;
}

abstract class BaseNotification implements NotificationChannel {
  constructor(protected serviceName: string) {}
  
  abstract send(recipient: string, message: string): Promise<boolean>;
  
  getChannelName(): string {
    return this.serviceName;
  }
  
  protected logNotification(recipient: string, status: string): void {
    console.log(`[${this.serviceName}] To: ${recipient} - Status: ${status}`);
  }
}

class EmailNotification extends BaseNotification {
  constructor() {
    super("Email");
  }
  
  async send(recipient: string, message: string): Promise<boolean> {
    console.log(`Sending email to ${recipient}...`);
    console.log(`Subject: Important Notification`);
    console.log(`Body: ${message}`);
    
    // Simulate async email sending
    await new Promise(resolve => setTimeout(resolve, 1000));
    
    const success = Math.random() > 0.1;
    this.logNotification(recipient, success ? "Delivered" : "Failed");
    return success;
  }
}

class SMSNotification extends BaseNotification {
  constructor() {
    super("SMS");
  }
  
  async send(recipient: string, message: string): Promise<boolean> {
    console.log(`Sending SMS to ${recipient}...`);
    console.log(`Message (${message.length} chars): ${message.substring(0, 50)}...`);
    
    // Simulate async SMS sending
    await new Promise(resolve => setTimeout(resolve, 500));
    
    const success = Math.random() > 0.05;
    this.logNotification(recipient, success ? "Sent" : "Failed");
    return success;
  }
}

class PushNotification extends BaseNotification {
  constructor() {
    super("Push");
  }
  
  async send(recipient: string, message: string): Promise<boolean> {
    console.log(`Sending push notification to device ${recipient}...`);
    console.log(`Alert: ${message}`);
    
    // Simulate async push notification
    await new Promise(resolve => setTimeout(resolve, 300));
    
    const success = Math.random() > 0.02;
    this.logNotification(recipient, success ? "Delivered" : "Not Delivered");
    return success;
  }
}

// Notification manager using polymorphism
class NotificationManager {
  private channels: NotificationChannel[] = [];
  
  registerChannel(channel: NotificationChannel): void {
    this.channels.push(channel);
    console.log(`βœ… Registered ${channel.getChannelName()} channel`);
  }
  
  async sendToAll(recipient: string, message: string): Promise<void> {
    console.log(`\nπŸ“’ Broadcasting message to ${this.channels.length} channels...\n`);
    
    const results = await Promise.all(
      this.channels.map(channel => channel.send(recipient, message))
    );
    
    const successCount = results.filter(r => r).length;
    console.log(`\nβœ… Successfully sent: ${successCount}/${this.channels.length}`);
  }
  
  async sendViaChannel(
    channelName: string,
    recipient: string,
    message: string
  ): Promise<boolean> {
    const channel = this.channels.find(
      c => c.getChannelName().toLowerCase() === channelName.toLowerCase()
    );
    
    if (!channel) {
      console.log(`❌ Channel '${channelName}' not found`);
      return false;
    }
    
    return await channel.send(recipient, message);
  }
}

// Usage
async function demo() {
  const manager = new NotificationManager();
  
  // Register notification channels
  manager.registerChannel(new EmailNotification());
  manager.registerChannel(new SMSNotification());
  manager.registerChannel(new PushNotification());
  
  // Send via all channels
  await manager.sendToAll(
    "user@example.com",
    "Your order #12345 has been shipped!"
  );
  
  // Send via specific channel
  console.log("\n--- Sending urgent SMS ---");
  await manager.sendViaChannel(
    "SMS",
    "+1234567890",
    "Your verification code is: 456789"
  );
}

demo();

Why polymorphism shines here:

  • The NotificationManager doesn't need to know implementation details of each channel
  • Adding Slack, Discord, or WhatsApp notifications requires zero changes to the manager
  • Each channel can have vastly different implementation (REST API, SMTP, WebSockets) but presents a uniform interface
  • Easy to mock channels for testing

Common Mistakes ⚠️

1. Forgetting to Call super() in Constructor

❌ Wrong:

class Dog extends Animal {
  constructor(name: string, breed: string) {
    // Missing super() call!
    this.breed = breed;  // Error!
  }
}

βœ… Correct:

class Dog extends Animal {
  constructor(name: string, private breed: string) {
    super(name);  // Must call parent constructor first!
  }
}

2. Accessing Private Members from Child Classes

❌ Wrong:

class Animal {
  private name: string;  // Private!
  
  constructor(name: string) {
    this.name = name;
  }
}

class Dog extends Animal {
  bark(): void {
    console.log(`${this.name} barks`);  // Error: name is private!
  }
}

βœ… Correct:

class Animal {
  protected name: string;  // Use protected instead!
  
  constructor(name: string) {
    this.name = name;
  }
}

class Dog extends Animal {
  bark(): void {
    console.log(`${this.name} barks`);  // Works!
  }
}

3. Incorrect Method Signature When Overriding

❌ Wrong:

class Shape {
  getArea(): number {
    return 0;
  }
}

class Circle extends Shape {
  // Different return type - not valid override!
  getArea(): string {  // Error!
    return "Area is 10";
  }
}

βœ… Correct:

class Circle extends Shape {
  override getArea(): number {  // Same signature
    return Math.PI * this.radius ** 2;
  }
}

4. Not Implementing All Abstract Methods

❌ Wrong:

abstract class Shape {
  abstract getArea(): number;
  abstract getPerimeter(): number;
}

class Circle extends Shape {
  // Missing getPerimeter()!
  getArea(): number {
    return Math.PI * this.radius ** 2;
  }
}  // Error: must implement getPerimeter()!

βœ… Correct:

class Circle extends Shape {
  getArea(): number {
    return Math.PI * this.radius ** 2;
  }
  
  getPerimeter(): number {
    return 2 * Math.PI * this.radius;
  }
}

5. Misusing Type Assertions Instead of Type Guards

❌ Wrong (unsafe):

function makeSound(animal: Animal): void {
  // Dangerous! What if it's not actually a Dog?
  (animal as Dog).bark();
}

βœ… Correct (safe):

function makeSound(animal: Animal): void {
  if (animal instanceof Dog) {
    animal.bark();  // TypeScript knows it's safe
  }
}

6. Creating Deep Inheritance Hierarchies

❌ Wrong (inheritance chain too deep):

class Animal { }
class Mammal extends Animal { }
class Carnivore extends Mammal { }
class Feline extends Carnivore { }
class BigCat extends Feline { }
class Lion extends BigCat { }  // Too deep!

βœ… Better (favor composition):

class Animal {
  constructor(
    private diet: Diet,
    private habitat: Habitat
  ) {}
}

class Lion extends Animal {
  constructor() {
    super(new CarnivoreDiet(), new SavannahHabitat());
  }
}

πŸ’‘ Best Practice: Limit inheritance depth to 2-3 levels. Beyond that, consider composition or interfaces.

Key Takeaways 🎯

πŸ“‹ Quick Reference Card

Concept Keyword/Syntax Purpose
Inheritance extends Create class hierarchies (is-a relationships)
Parent access super(), super.method() Call parent constructor or methods
Method override override keyword Replace parent method implementation
Abstract class abstract class Base class that cannot be instantiated
Abstract method abstract methodName() Method that must be implemented by children
Interface interface Contract defining shape of objects
Implement interface implements Promise to fulfill interface contract
Type guard instanceof Check object type at runtime
Access modifiers public, protected, private Control member visibility
Polymorphism Method overriding + parent type references Treat different types uniformly

🧠 Core Principles

1. Inheritance creates "is-a" relationships

  • A Dog is an Animal
  • A Manager is an Employee
  • Use when specialization makes sense

2. Polymorphism enables flexibility

  • Write code once, work with many types
  • Parent type reference, child type behavior
  • Runtime method resolution

3. Abstract classes enforce contracts

  • Cannot be instantiated directly
  • Mix abstract and concrete methods
  • Provide shared implementation

4. Interfaces define capabilities

  • Pure contracts with no implementation
  • Support multiple inheritance
  • Ideal for "can-do" relationships

5. Protected vs Private

  • private: Only within the class
  • protected: Within class and children
  • public: Everywhere (default)

6. Favor composition over deep inheritance

  • Limit inheritance depth to 2-3 levels
  • Use interfaces for multiple capabilities
  • Compose objects from smaller parts

Further Study πŸ“š

Ready to dive deeper? Here are excellent resources:

  1. TypeScript Handbook - Classes: https://www.typescriptlang.org/docs/handbook/2/classes.html - Official documentation with comprehensive examples

  2. Refactoring Guru - OOP Concepts: https://refactoring.guru/design-patterns/what-is-pattern - Learn design patterns that leverage inheritance and polymorphism

  3. TypeScript Deep Dive - Classes: https://basarat.gitbook.io/typescript/future-javascript/classes - Advanced patterns and best practices


πŸŽ‰ Congratulations! You've mastered inheritance and polymorphism in TypeScript. These foundational concepts will serve you throughout your object-oriented programming journey. Practice building class hierarchies, experiment with different designs, and remember: good OOP design favors flexibility and maintainability over clever tricks!

πŸ”₯ Next Steps: Try refactoring some existing code to use inheritance and polymorphism. Look for repeated patterns, "is-a" relationships, and opportunities to treat different types uniformly. The more you practice, the more natural these concepts will become!