OOP
01 / 10

Inheritance and Polymorphism

Inheritance and Polymorphism

Master advanced OOP concepts that enable code reuse and flexible design through inheritance hierarchies and polymorphic behavior.

Inheritance Hierarchies

Inheritance creates a parent-child relationship between classes, allowing child classes to inherit and extend parent functionality.

// Base class
class User {
  protected id: string;
  protected email: string;
  protected createdAt: Date;

  constructor(id: string, email: string) {
    this.id = id;
    this.email = email;
    this.createdAt = new Date();
  }

  public getProfile(): string {
    return `User: ${this.email} (ID: ${this.id})`;
  }

  public canAccessResource(resourceId: string): boolean {
    return false; // Base users can't access resources
  }
}

// First level inheritance
class RegisteredUser extends User {
  protected username: string;
  protected lastLogin: Date;

  constructor(id: string, email: string, username: string) {
    super(id, email);
    this.username = username;
    this.lastLogin = new Date();
  }

  public override getProfile(): string {
    return `${this.username} (${this.email})`;
  }

  public override canAccessResource(resourceId: string): boolean {
    return true; // Registered users can access basic resources
  }

  public updateLastLogin(): void {
    this.lastLogin = new Date();
  }
}

// Second level inheritance
class PremiumUser extends RegisteredUser {
  private subscriptionEndDate: Date;
  private features: string[];

  constructor(
    id: string,
    email: string,
    username: string,
    subscriptionMonths: number
  ) {
    super(id, email, username);
    this.subscriptionEndDate = new Date();
    this.subscriptionEndDate.setMonth(
      this.subscriptionEndDate.getMonth() + subscriptionMonths
    );
    this.features = ["ad-free", "HD-streaming", "offline-mode"];
  }

  public override getProfile(): string {
    return `⭐ PREMIUM: ${super.getProfile()}`;
  }

  public override canAccessResource(resourceId: string): boolean {
    // Premium users can access all resources
    return this.isSubscriptionActive();
  }

  private isSubscriptionActive(): boolean {
    return new Date() < this.subscriptionEndDate;
  }

  public getFeatures(): string[] {
    return [...this.features];
  }
}

class AdminUser extends RegisteredUser {
  private permissions: Set<string>;

  constructor(id: string, email: string, username: string) {
    super(id, email, username);
    this.permissions = new Set(["read", "write", "delete", "admin"]);
  }

  public override getProfile(): string {
    return `🛡️ ADMIN: ${super.getProfile()}`;
  }

  public override canAccessResource(resourceId: string): boolean {
    return true; // Admins can access everything
  }

  public hasPermission(permission: string): boolean {
    return this.permissions.has(permission);
  }

  public deleteUser(userId: string): void {
    console.log(`Admin deleted user: ${userId}`);
  }
}

// Usage
const users: User[] = [
  new User("1", "guest@example.com"),
  new RegisteredUser("2", "user@example.com", "john_doe"),
  new PremiumUser("3", "premium@example.com", "jane_premium", 12),
  new AdminUser("4", "admin@example.com", "admin_user")
];

users.forEach(user => {
  console.log(user.getProfile());
  console.log(`Can access: ${user.canAccessResource("resource-1")}`);
});

Method Overriding

Child classes can provide their own implementation of methods inherited from parent classes.

class PaymentProcessor {
  protected processingFee: number = 0;

  processPayment(amount: number): number {
    const fee = this.calculateFee(amount);
    const total = amount + fee;
    console.log(`Processing payment: $${amount} + $${fee} fee = $${total}`);
    return total;
  }

  protected calculateFee(amount: number): number {
    return 0; // Base class has no fee
  }
}

class CreditCardProcessor extends PaymentProcessor {
  protected override calculateFee(amount: number): number {
    return amount * 0.029 + 0.30; // 2.9% + $0.30
  }
}

class PayPalProcessor extends PaymentProcessor {
  protected override calculateFee(amount: number): number {
    return amount * 0.034 + 0.49; // 3.4% + $0.49
  }
}

class WireTransferProcessor extends PaymentProcessor {
  protected override calculateFee(amount: number): number {
    return 25.00; // Flat fee
  }
}

// Same method, different behavior based on object type
function processAllPayments(processors: PaymentProcessor[], amount: number): void {
  processors.forEach(processor => {
    processor.processPayment(amount);
  });
}

const processors = [
  new PaymentProcessor(),
  new CreditCardProcessor(),
  new PayPalProcessor(),
  new WireTransferProcessor()
];

processAllPayments(processors, 100);

Abstract Classes

Abstract classes cannot be instantiated directly and are meant to be extended. They can contain abstract methods that must be implemented by child classes.

abstract class DatabaseConnection {
  protected connectionString: string;
  protected isConnected: boolean = false;

  constructor(connectionString: string) {
    this.connectionString = connectionString;
  }

  // Abstract methods - must be implemented by subclasses
  abstract connect(): Promise<void>;
  abstract disconnect(): Promise<void>;
  abstract query(sql: string): Promise<any[]>;
  abstract execute(sql: string): Promise<number>;

  // Concrete method - shared by all connections
  public getStatus(): string {
    return this.isConnected ? "Connected" : "Disconnected";
  }

  protected log(message: string): void {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
}

class MySQLConnection extends DatabaseConnection {
  async connect(): Promise<void> {
    this.log(`Connecting to MySQL: ${this.connectionString}`);
    // Simulate connection
    await new Promise(resolve => setTimeout(resolve, 100));
    this.isConnected = true;
    this.log("MySQL connected successfully");
  }

  async disconnect(): Promise<void> {
    this.log("Disconnecting from MySQL");
    this.isConnected = false;
  }

  async query(sql: string): Promise<any[]> {
    if (!this.isConnected) throw new Error("Not connected");
    this.log(`Executing query: ${sql}`);
    return []; // Simulated result
  }

  async execute(sql: string): Promise<number> {
    if (!this.isConnected) throw new Error("Not connected");
    this.log(`Executing command: ${sql}`);
    return 1; // Simulated affected rows
  }
}

class PostgreSQLConnection extends DatabaseConnection {
  async connect(): Promise<void> {
    this.log(`Connecting to PostgreSQL: ${this.connectionString}`);
    await new Promise(resolve => setTimeout(resolve, 100));
    this.isConnected = true;
    this.log("PostgreSQL connected successfully");
  }

  async disconnect(): Promise<void> {
    this.log("Disconnecting from PostgreSQL");
    this.isConnected = false;
  }

  async query(sql: string): Promise<any[]> {
    if (!this.isConnected) throw new Error("Not connected");
    this.log(`PG Query: ${sql}`);
    return [];
  }

  async execute(sql: string): Promise<number> {
    if (!this.isConnected) throw new Error("Not connected");
    this.log(`PG Execute: ${sql}`);
    return 1;
  }
}

// Usage
async function databaseExample() {
  const connections: DatabaseConnection[] = [
    new MySQLConnection("mysql://localhost:3306/mydb"),
    new PostgreSQLConnection("postgresql://localhost:5432/mydb")
  ];

  for (const conn of connections) {
    await conn.connect();
    await conn.query("SELECT * FROM users");
    console.log(`Status: ${conn.getStatus()}`);
    await conn.disconnect();
  }
}

Interfaces vs Abstract Classes

Understanding when to use interfaces versus abstract classes:

// Interface: Contract for implementation
interface Printable {
  print(): void;
  getPrintData(): string;
}

// Interface: Can extend multiple interfaces
interface Saveable {
  save(): Promise<void>;
  load(): Promise<void>;
}

// A class can implement multiple interfaces
class Document implements Printable, Saveable {
  constructor(
    private content: string,
    private filename: string
  ) {}

  print(): void {
    console.log(this.getPrintData());
  }

  getPrintData(): string {
    return `Document: ${this.filename}\n${this.content}`;
  }

  async save(): Promise<void> {
    console.log(`Saving ${this.filename}...`);
    // Save logic
  }

  async load(): Promise<void> {
    console.log(`Loading ${this.filename}...`);
    // Load logic
  }
}

// Abstract class: Provides base implementation
abstract class Report {
  protected title: string;
  protected generatedAt: Date;

  constructor(title: string) {
    this.title = title;
    this.generatedAt = new Date();
  }

  // Concrete method with implementation
  public getMetadata(): string {
    return `Report: ${this.title} (Generated: ${this.generatedAt.toISOString()})`;
  }

  // Abstract method - must be implemented
  abstract generateContent(): string;
  abstract export(format: string): string;
}

class SalesReport extends Report {
  constructor(
    title: string,
    private salesData: any[]
  ) {
    super(title);
  }

  generateContent(): string {
    return `Sales Report Content: ${this.salesData.length} records`;
  }

  export(format: string): string {
    return `Exporting sales report to ${format}`;
  }
}

// When to use which:
// - Use INTERFACE when you need multiple inheritance or pure contracts
// - Use ABSTRACT CLASS when you have shared implementation and state

Composition over Inheritance

While inheritance is powerful, composition is often preferred for building flexible systems.

// Instead of deep inheritance, use composition
interface Logger {
  log(message: string): void;
}

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(`[LOG] ${message}`);
  }
}

class FileLogger implements Logger {
  constructor(private filename: string) {}
  
  log(message: string): void {
    console.log(`[FILE: ${this.filename}] ${message}`);
  }
}

interface Cache {
  get(key: string): any;
  set(key: string, value: any): void;
}

class MemoryCache implements Cache {
  private store = new Map();

  get(key: string): any {
    return this.store.get(key);
  }

  set(key: string, value: any): void {
    this.store.set(key, value);
  }
}

// Using composition
class UserService {
  constructor(
    private logger: Logger,
    private cache: Cache
  ) {}

  async getUser(id: string): Promise<any> {
    this.logger.log(`Fetching user: ${id}`);
    
    // Check cache first
    const cached = this.cache.get(`user_${id}`);
    if (cached) {
      this.logger.log(`User ${id} found in cache`);
      return cached;
    }

    // Fetch from database (simulated)
    const user = { id, name: "John Doe" };
    this.cache.set(`user_${id}`, user);
    this.logger.log(`User ${id} cached`);
    
    return user;
  }
}

// Flexible - can swap implementations easily
const service1 = new UserService(
  new ConsoleLogger(),
  new MemoryCache()
);

const service2 = new UserService(
  new FileLogger("app.log"),
  new MemoryCache()
);

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free