Back to blog

Polymorphism and Interfaces: One Interface, Many Forms

ooppolymorphisminterfacesdesign-patternstypescriptpythonjava
Polymorphism and Interfaces: One Interface, Many Forms

Introduction

The word polymorphism comes from Greek, meaning "many forms." In object-oriented programming, it's one of the most powerful concepts—the ability for different objects to respond to the same message in different ways.

When combined with interfaces, polymorphism enables you to write flexible, extensible code that depends on abstractions rather than concrete implementations. This is the foundation of many design patterns and the key to building maintainable software.

What You'll Learn

✅ Understand polymorphism and its "many forms" concept
✅ Master compile-time (static) polymorphism: method overloading
✅ Master runtime (dynamic) polymorphism: method overriding
✅ Design with interfaces and abstract contracts
✅ Understand duck typing in dynamic languages
✅ Apply the Liskov Substitution Principle (LSP)

Prerequisites


Understanding Polymorphism

Polymorphism allows objects of different types to be treated as objects of a common type. The same method call can produce different behavior depending on the actual object type.

The "Many Forms" Concept

Think of a universal remote control. When you press "Power," the TV turns on. Press the same button with a different device selected, and the stereo turns on. Same button (interface), different behavior (implementation).

// TypeScript - Polymorphism example
class Animal {
  name: string;
 
  constructor(name: string) {
    this.name = name;
  }
 
  speak(): void {
    console.log(`${this.name} makes a sound`);
  }
}
 
class Dog extends Animal {
  speak(): void {
    console.log(`${this.name} barks: Woof!`);
  }
}
 
class Cat extends Animal {
  speak(): void {
    console.log(`${this.name} meows: Meow!`);
  }
}
 
class Duck extends Animal {
  speak(): void {
    console.log(`${this.name} quacks: Quack!`);
  }
}
 
// Polymorphism in action - same method, different behavior
function makeAnimalSpeak(animal: Animal): void {
  animal.speak(); // Which speak() is called depends on actual type
}
 
const animals: Animal[] = [
  new Dog("Rex"),
  new Cat("Whiskers"),
  new Duck("Donald")
];
 
animals.forEach(animal => makeAnimalSpeak(animal));
// Output:
// Rex barks: Woof!
// Whiskers meows: Meow!
// Donald quacks: Quack!

The makeAnimalSpeak function doesn't know or care about the specific animal type. It just calls speak(), and polymorphism ensures the correct implementation runs.


Two Types of Polymorphism

Polymorphism comes in two flavors, distinguished by when the method to call is determined:

1. Compile-Time (Static) Polymorphism

Resolved at compile time. The compiler determines which method to call based on:

  • Method signature (overloading)
  • Parameter types
  • Number of parameters

Also known as: Method overloading, static binding, early binding

2. Runtime (Dynamic) Polymorphism

Resolved at runtime. The actual method called depends on:

  • The actual object type (not the declared type)
  • Method overriding in subclasses

Also known as: Method overriding, dynamic binding, late binding


Compile-Time Polymorphism: Method Overloading

Method overloading allows multiple methods with the same name but different parameters in the same class.

Java Example (Full Overloading Support)

Java supports method overloading natively:

// Java - Method overloading
public class Calculator {
    // Same method name, different parameter types
    public int add(int a, int b) {
        return a + b;
    }
 
    public double add(double a, double b) {
        return a + b;
    }
 
    public int add(int a, int b, int c) {
        return a + b + c;
    }
 
    public String add(String a, String b) {
        return a + b; // String concatenation
    }
}
 
// Usage
Calculator calc = new Calculator();
System.out.println(calc.add(2, 3));           // 5 (int version)
System.out.println(calc.add(2.5, 3.5));       // 6.0 (double version)
System.out.println(calc.add(1, 2, 3));        // 6 (three-parameter version)
System.out.println(calc.add("Hello, ", "World")); // "Hello, World"

The compiler determines which add() method to call based on the argument types.

TypeScript Overloading (Signature-Based)

TypeScript uses overload signatures with a single implementation:

// TypeScript - Overload signatures
class Calculator {
  // Overload signatures
  add(a: number, b: number): number;
  add(a: string, b: string): string;
  add(a: number, b: number, c: number): number;
 
  // Single implementation
  add(a: number | string, b: number | string, c?: number): number | string {
    if (typeof a === "string" && typeof b === "string") {
      return a + b;
    }
    if (typeof a === "number" && typeof b === "number") {
      return c !== undefined ? a + b + c : a + b;
    }
    throw new Error("Invalid arguments");
  }
}
 
const calc = new Calculator();
console.log(calc.add(2, 3));              // 5
console.log(calc.add("Hello, ", "World")); // "Hello, World"
console.log(calc.add(1, 2, 3));           // 6

Python (No Traditional Overloading)

Python doesn't support method overloading in the traditional sense. The last method definition wins:

# Python - No traditional overloading
class Calculator:
    # This method will be overwritten!
    def add(self, a: int, b: int) -> int:
        return a + b
 
    # Only this method exists
    def add(self, a: float, b: float) -> float:
        return a + b
 
calc = Calculator()
# calc.add(1, 2) would call the float version

Instead, Python uses default parameters, *args, or @singledispatch:

# Python - Alternatives to overloading
from functools import singledispatch
 
# Option 1: Default parameters
class Calculator:
    def add(self, a, b, c=None):
        if c is not None:
            return a + b + c
        return a + b
 
# Option 2: Using singledispatch for type-based dispatch
@singledispatch
def add(a, b):
    raise NotImplementedError(f"Unsupported types: {type(a)}, {type(b)}")
 
@add.register(int)
def _(a: int, b: int) -> int:
    return a + b
 
@add.register(str)
def _(a: str, b: str) -> str:
    return a + b
 
@add.register(float)
def _(a: float, b: float) -> float:
    return a + b
 
# Usage
print(add(2, 3))              # 5
print(add("Hello, ", "World")) # "Hello, World"
print(add(2.5, 3.5))          # 6.0

Runtime Polymorphism: Method Overriding

Method overriding occurs when a subclass provides its own implementation of a method defined in its parent class.

Basic Method Overriding

// TypeScript - Method overriding
class Shape {
  getArea(): number {
    return 0;
  }
 
  describe(): string {
    return `A shape with area ${this.getArea()}`;
  }
}
 
class Circle extends Shape {
  constructor(private radius: number) {
    super();
  }
 
  // Override getArea
  getArea(): number {
    return Math.PI * this.radius ** 2;
  }
}
 
class Rectangle extends Shape {
  constructor(
    private width: number,
    private height: number
  ) {
    super();
  }
 
  // Override getArea
  getArea(): number {
    return this.width * this.height;
  }
}
 
class Triangle extends Shape {
  constructor(
    private base: number,
    private height: number
  ) {
    super();
  }
 
  // Override getArea
  getArea(): number {
    return 0.5 * this.base * this.height;
  }
}
 
// Runtime polymorphism - correct getArea() called based on actual type
function calculateTotalArea(shapes: Shape[]): number {
  return shapes.reduce((total, shape) => total + shape.getArea(), 0);
}
 
const shapes: Shape[] = [
  new Circle(5),           // Area: 78.54
  new Rectangle(4, 6),     // Area: 24
  new Triangle(3, 4)       // Area: 6
];
 
console.log(calculateTotalArea(shapes)); // 108.54...
shapes.forEach(shape => console.log(shape.describe()));
# Python - Method overriding
import math
 
class Shape:
    def get_area(self) -> float:
        return 0
 
    def describe(self) -> str:
        return f"A shape with area {self.get_area()}"
 
class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius
 
    def get_area(self) -> float:
        return math.pi * self.radius ** 2
 
class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height
 
    def get_area(self) -> float:
        return self.width * self.height
 
class Triangle(Shape):
    def __init__(self, base: float, height: float):
        self.base = base
        self.height = height
 
    def get_area(self) -> float:
        return 0.5 * self.base * self.height
 
# Runtime polymorphism
def calculate_total_area(shapes: list[Shape]) -> float:
    return sum(shape.get_area() for shape in shapes)
 
shapes = [
    Circle(5),
    Rectangle(4, 6),
    Triangle(3, 4)
]
 
print(calculate_total_area(shapes))  # 108.54...
// Java - Method overriding with @Override annotation
public abstract class Shape {
    public abstract double getArea();
 
    public String describe() {
        return "A shape with area " + getArea();
    }
}
 
public class Circle extends Shape {
    private double radius;
 
    public Circle(double radius) {
        this.radius = radius;
    }
 
    @Override
    public double getArea() {
        return Math.PI * radius * radius;
    }
}
 
public class Rectangle extends Shape {
    private double width;
    private double height;
 
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
 
    @Override
    public double getArea() {
        return width * height;
    }
}

Key Differences: Overloading vs Overriding

AspectOverloadingOverriding
When resolvedCompile timeRuntime
WhereSame classParent-child classes
Method signatureMust differMust be same
Return typeCan differMust be same or covariant
Access modifierCan differCan't be more restrictive
PurposeMultiple ways to call same operationSpecialize behavior

Interfaces: Contracts for Polymorphism

Interfaces define a contract—a set of methods that implementing classes must provide. They're the purest form of abstraction and the foundation for polymorphic design.

Defining and Implementing Interfaces

// TypeScript - Interface definition
interface IPaymentProcessor {
  processPayment(amount: number): boolean;
  refund(transactionId: string): boolean;
  getTransactionHistory(): string[];
}
 
// Implementing classes must fulfill the contract
class CreditCardProcessor implements IPaymentProcessor {
  private transactions: string[] = [];
 
  processPayment(amount: number): boolean {
    console.log(`Processing $${amount} via Credit Card`);
    const txId = `CC-${Date.now()}`;
    this.transactions.push(txId);
    return true;
  }
 
  refund(transactionId: string): boolean {
    console.log(`Refunding transaction ${transactionId}`);
    return true;
  }
 
  getTransactionHistory(): string[] {
    return [...this.transactions];
  }
}
 
class PayPalProcessor implements IPaymentProcessor {
  private transactions: string[] = [];
 
  processPayment(amount: number): boolean {
    console.log(`Processing $${amount} via PayPal`);
    const txId = `PP-${Date.now()}`;
    this.transactions.push(txId);
    return true;
  }
 
  refund(transactionId: string): boolean {
    console.log(`Refunding PayPal transaction ${transactionId}`);
    return true;
  }
 
  getTransactionHistory(): string[] {
    return [...this.transactions];
  }
}
 
class CryptoProcessor implements IPaymentProcessor {
  private transactions: string[] = [];
 
  processPayment(amount: number): boolean {
    console.log(`Processing $${amount} via Cryptocurrency`);
    const txId = `CRYPTO-${Date.now()}`;
    this.transactions.push(txId);
    return true;
  }
 
  refund(transactionId: string): boolean {
    console.log(`Crypto refunds not supported for ${transactionId}`);
    return false; // Crypto refunds might not be possible
  }
 
  getTransactionHistory(): string[] {
    return [...this.transactions];
  }
}
 
// Client code depends on interface, not implementation
class CheckoutService {
  constructor(private paymentProcessor: IPaymentProcessor) {}
 
  checkout(amount: number): boolean {
    console.log(`Checkout for $${amount}`);
    return this.paymentProcessor.processPayment(amount);
  }
 
  // Can change processor at runtime
  setPaymentProcessor(processor: IPaymentProcessor): void {
    this.paymentProcessor = processor;
  }
}
 
// Usage - polymorphism through interfaces
const checkout = new CheckoutService(new CreditCardProcessor());
checkout.checkout(100); // Processing $100 via Credit Card
 
checkout.setPaymentProcessor(new PayPalProcessor());
checkout.checkout(50); // Processing $50 via PayPal
 
checkout.setPaymentProcessor(new CryptoProcessor());
checkout.checkout(200); // Processing $200 via Cryptocurrency
# Python - Interface using ABC (Abstract Base Class)
from abc import ABC, abstractmethod
 
class IPaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass
 
    @abstractmethod
    def refund(self, transaction_id: str) -> bool:
        pass
 
    @abstractmethod
    def get_transaction_history(self) -> list[str]:
        pass
 
class CreditCardProcessor(IPaymentProcessor):
    def __init__(self):
        self._transactions: list[str] = []
 
    def process_payment(self, amount: float) -> bool:
        print(f"Processing ${amount} via Credit Card")
        tx_id = f"CC-{id(self)}"
        self._transactions.append(tx_id)
        return True
 
    def refund(self, transaction_id: str) -> bool:
        print(f"Refunding transaction {transaction_id}")
        return True
 
    def get_transaction_history(self) -> list[str]:
        return self._transactions.copy()
 
class PayPalProcessor(IPaymentProcessor):
    def __init__(self):
        self._transactions: list[str] = []
 
    def process_payment(self, amount: float) -> bool:
        print(f"Processing ${amount} via PayPal")
        tx_id = f"PP-{id(self)}"
        self._transactions.append(tx_id)
        return True
 
    def refund(self, transaction_id: str) -> bool:
        print(f"Refunding PayPal transaction {transaction_id}")
        return True
 
    def get_transaction_history(self) -> list[str]:
        return self._transactions.copy()
 
# Client code depends on interface
class CheckoutService:
    def __init__(self, payment_processor: IPaymentProcessor):
        self._processor = payment_processor
 
    def checkout(self, amount: float) -> bool:
        print(f"Checkout for ${amount}")
        return self._processor.process_payment(amount)
 
    def set_payment_processor(self, processor: IPaymentProcessor):
        self._processor = processor
// Java - Interface definition
public interface IPaymentProcessor {
    boolean processPayment(double amount);
    boolean refund(String transactionId);
    List<String> getTransactionHistory();
}
 
public class CreditCardProcessor implements IPaymentProcessor {
    private List<String> transactions = new ArrayList<>();
 
    @Override
    public boolean processPayment(double amount) {
        System.out.println("Processing $" + amount + " via Credit Card");
        transactions.add("CC-" + System.currentTimeMillis());
        return true;
    }
 
    @Override
    public boolean refund(String transactionId) {
        System.out.println("Refunding transaction " + transactionId);
        return true;
    }
 
    @Override
    public List<String> getTransactionHistory() {
        return new ArrayList<>(transactions);
    }
}

Multiple Interface Implementation

Unlike classes (single inheritance in most languages), a class can implement multiple interfaces:

// TypeScript - Multiple interfaces
interface IReadable {
  read(): string;
}
 
interface IWritable {
  write(data: string): void;
}
 
interface IClosable {
  close(): void;
  isOpen(): boolean;
}
 
// Implement multiple interfaces
class FileHandler implements IReadable, IWritable, IClosable {
  private content: string = "";
  private open: boolean = true;
 
  read(): string {
    if (!this.open) throw new Error("File is closed");
    return this.content;
  }
 
  write(data: string): void {
    if (!this.open) throw new Error("File is closed");
    this.content += data;
  }
 
  close(): void {
    this.open = false;
  }
 
  isOpen(): boolean {
    return this.open;
  }
}
 
// Functions can require specific interfaces
function copyContent(source: IReadable, destination: IWritable): void {
  const content = source.read();
  destination.write(content);
}
 
function safeClose(resource: IClosable): void {
  if (resource.isOpen()) {
    resource.close();
  }
}
# Python - Multiple interface implementation
from abc import ABC, abstractmethod
 
class IReadable(ABC):
    @abstractmethod
    def read(self) -> str:
        pass
 
class IWritable(ABC):
    @abstractmethod
    def write(self, data: str) -> None:
        pass
 
class IClosable(ABC):
    @abstractmethod
    def close(self) -> None:
        pass
 
    @abstractmethod
    def is_open(self) -> bool:
        pass
 
# Implement multiple interfaces
class FileHandler(IReadable, IWritable, IClosable):
    def __init__(self):
        self._content = ""
        self._open = True
 
    def read(self) -> str:
        if not self._open:
            raise RuntimeError("File is closed")
        return self._content
 
    def write(self, data: str) -> None:
        if not self._open:
            raise RuntimeError("File is closed")
        self._content += data
 
    def close(self) -> None:
        self._open = False
 
    def is_open(self) -> bool:
        return self._open
// Java - Multiple interfaces
public interface IReadable {
    String read();
}
 
public interface IWritable {
    void write(String data);
}
 
public interface IClosable {
    void close();
    boolean isOpen();
}
 
public class FileHandler implements IReadable, IWritable, IClosable {
    private StringBuilder content = new StringBuilder();
    private boolean open = true;
 
    @Override
    public String read() {
        if (!open) throw new IllegalStateException("File is closed");
        return content.toString();
    }
 
    @Override
    public void write(String data) {
        if (!open) throw new IllegalStateException("File is closed");
        content.append(data);
    }
 
    @Override
    public void close() {
        open = false;
    }
 
    @Override
    public boolean isOpen() {
        return open;
    }
}

Abstract Classes vs Interfaces

Both provide abstraction, but they serve different purposes:

Comparison Table

AspectAbstract ClassInterface
MethodsCan have implementationOnly signatures (mostly)
FieldsCan have instance fieldsOnly constants (Java)
ConstructorsYesNo
InheritanceSingleMultiple
PurposeShared implementationDefine contract
"Is-a" vs "Can-do"Is-a relationshipCan-do capability

When to Use Abstract Classes

  • When you have shared implementation across subclasses
  • When you need constructors or instance fields
  • When the relationship is truly "is-a"
// Abstract class - shared implementation
abstract class DatabaseConnection {
  protected host: string;
  protected port: number;
  protected connected: boolean = false;
 
  constructor(host: string, port: number) {
    this.host = host;
    this.port = port;
  }
 
  // Shared implementation
  connect(): void {
    console.log(`Connecting to ${this.host}:${this.port}`);
    this.performConnection();
    this.connected = true;
  }
 
  // Abstract - must be implemented
  protected abstract performConnection(): void;
  abstract executeQuery(query: string): any[];
 
  // Shared implementation
  disconnect(): void {
    if (this.connected) {
      console.log("Disconnecting...");
      this.connected = false;
    }
  }
}
 
class PostgresConnection extends DatabaseConnection {
  protected performConnection(): void {
    console.log("PostgreSQL handshake...");
  }
 
  executeQuery(query: string): any[] {
    console.log(`PostgreSQL executing: ${query}`);
    return [];
  }
}
 
class MySQLConnection extends DatabaseConnection {
  protected performConnection(): void {
    console.log("MySQL handshake...");
  }
 
  executeQuery(query: string): any[] {
    console.log(`MySQL executing: ${query}`);
    return [];
  }
}

When to Use Interfaces

  • When you want to define a contract without implementation
  • When you need multiple inheritance of types
  • When unrelated classes need to share a capability
// Interface - contract only
interface ISerializable {
  serialize(): string;
  deserialize(data: string): void;
}
 
interface IComparable<T> {
  compareTo(other: T): number;
}
 
interface ICloneable<T> {
  clone(): T;
}
 
// Unrelated classes can implement same interface
class User implements ISerializable, IComparable<User> {
  constructor(
    public id: number,
    public name: string,
    public email: string
  ) {}
 
  serialize(): string {
    return JSON.stringify({ id: this.id, name: this.name, email: this.email });
  }
 
  deserialize(data: string): void {
    const obj = JSON.parse(data);
    this.id = obj.id;
    this.name = obj.name;
    this.email = obj.email;
  }
 
  compareTo(other: User): number {
    return this.id - other.id;
  }
}
 
class Product implements ISerializable, IComparable<Product> {
  constructor(
    public sku: string,
    public price: number
  ) {}
 
  serialize(): string {
    return JSON.stringify({ sku: this.sku, price: this.price });
  }
 
  deserialize(data: string): void {
    const obj = JSON.parse(data);
    this.sku = obj.sku;
    this.price = obj.price;
  }
 
  compareTo(other: Product): number {
    return this.price - other.price;
  }
}

Duck Typing: "If It Quacks Like a Duck..."

Duck typing is a form of polymorphism in dynamic languages where an object's suitability is determined by its methods and properties, not its class.

"If it walks like a duck and quacks like a duck, then it must be a duck."

Python Duck Typing

# Python - Duck typing (no interface needed!)
class Duck:
    def speak(self):
        print("Quack!")
 
    def walk(self):
        print("Waddle waddle")
 
class Person:
    def speak(self):
        print("Hello!")
 
    def walk(self):
        print("Step step")
 
class Robot:
    def speak(self):
        print("Beep boop!")
 
    def walk(self):
        print("Clank clank")
 
# No common base class or interface needed
def make_it_walk_and_talk(thing):
    thing.walk()
    thing.speak()
 
# All work because they have walk() and speak()
make_it_walk_and_talk(Duck())    # Waddle waddle, Quack!
make_it_walk_and_talk(Person())  # Step step, Hello!
make_it_walk_and_talk(Robot())   # Clank clank, Beep boop!

TypeScript Structural Typing

TypeScript uses structural typing — types are compatible if their structures match:

// TypeScript - Structural typing (like duck typing with types)
interface Walkable {
  walk(): void;
}
 
interface Talkable {
  speak(): void;
}
 
// No explicit implements needed if structure matches
class Duck {
  speak(): void {
    console.log("Quack!");
  }
 
  walk(): void {
    console.log("Waddle waddle");
  }
}
 
class Robot {
  speak(): void {
    console.log("Beep boop!");
  }
 
  walk(): void {
    console.log("Clank clank");
  }
 
  charge(): void {
    console.log("Charging...");
  }
}
 
// Both work even though Robot doesn't explicitly implement the interfaces
function makeItWalkAndTalk(thing: Walkable & Talkable): void {
  thing.walk();
  thing.speak();
}
 
makeItWalkAndTalk(new Duck());  // Works!
makeItWalkAndTalk(new Robot()); // Works! (extra methods are fine)

Benefits and Risks of Duck Typing

Benefits:

  • More flexible code
  • Less boilerplate (no interface declarations needed)
  • Easier to work with third-party code

Risks:

  • Runtime errors if method doesn't exist
  • Less explicit contracts
  • Harder to understand dependencies

The Liskov Substitution Principle (LSP)

The Liskov Substitution Principle states:

Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.

This is the "L" in SOLID and is fundamental to proper polymorphism.

LSP Violation Example: Square and Rectangle

The classic example of an LSP violation:

// LSP Violation - Square extends Rectangle
class Rectangle {
  protected width: number;
  protected height: number;
 
  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }
 
  setWidth(width: number): void {
    this.width = width;
  }
 
  setHeight(height: number): void {
    this.height = height;
  }
 
  getArea(): number {
    return this.width * this.height;
  }
}
 
class Square extends Rectangle {
  constructor(size: number) {
    super(size, size);
  }
 
  // Must override to maintain square invariant
  setWidth(width: number): void {
    this.width = width;
    this.height = width; // Must keep it square!
  }
 
  setHeight(height: number): void {
    this.height = height;
    this.width = height; // Must keep it square!
  }
}
 
// This function works with Rectangle but breaks with Square
function doubleWidth(rect: Rectangle): void {
  const originalHeight = rect.getArea() / rect.getArea() * rect.getArea();
  rect.setWidth(rect.getArea() / 10); // Some width manipulation
}
 
// Test LSP
function testLSP(rect: Rectangle): void {
  rect.setWidth(5);
  rect.setHeight(4);
  const area = rect.getArea();
 
  // Expected: 5 * 4 = 20
  console.log(`Expected area: 20, Actual: ${area}`);
 
  if (area !== 20) {
    console.log("LSP VIOLATION! Square changed both dimensions");
  }
}
 
testLSP(new Rectangle(1, 1)); // Expected: 20, Actual: 20 ✓
testLSP(new Square(1));       // Expected: 20, Actual: 16 ✗ (4*4)

LSP-Compliant Design

// LSP-Compliant - Use interface instead of inheritance
interface Shape {
  getArea(): number;
}
 
class Rectangle implements Shape {
  constructor(
    private width: number,
    private height: number
  ) {}
 
  getWidth(): number {
    return this.width;
  }
 
  getHeight(): number {
    return this.height;
  }
 
  setWidth(width: number): void {
    this.width = width;
  }
 
  setHeight(height: number): void {
    this.height = height;
  }
 
  getArea(): number {
    return this.width * this.height;
  }
}
 
class Square implements Shape {
  constructor(private size: number) {}
 
  getSize(): number {
    return this.size;
  }
 
  setSize(size: number): void {
    this.size = size;
  }
 
  getArea(): number {
    return this.size * this.size;
  }
}
 
// No inheritance relationship - no LSP violation possible
// Each class has its own appropriate methods
function calculateTotalArea(shapes: Shape[]): number {
  return shapes.reduce((total, shape) => total + shape.getArea(), 0);
}

LSP Guidelines

  1. Preconditions cannot be strengthened in a subclass
  2. Postconditions cannot be weakened in a subclass
  3. Invariants must be preserved in all subclasses
  4. Behavior must be consistent with parent class expectations

Interface Segregation

The Interface Segregation Principle (ISP) states that clients shouldn't depend on interfaces they don't use. This is closely related to interface-based polymorphism.

Problem: Fat Interface

// Problem: Fat interface
interface IWorker {
  work(): void;
  eat(): void;
  sleep(): void;
  attendMeeting(): void;
  writeReport(): void;
}
 
class HumanWorker implements IWorker {
  work(): void { console.log("Working..."); }
  eat(): void { console.log("Eating lunch..."); }
  sleep(): void { console.log("Sleeping..."); }
  attendMeeting(): void { console.log("In meeting..."); }
  writeReport(): void { console.log("Writing report..."); }
}
 
class RobotWorker implements IWorker {
  work(): void { console.log("Processing tasks..."); }
  eat(): void { /* Robots don't eat! */ }
  sleep(): void { /* Robots don't sleep! */ }
  attendMeeting(): void { console.log("Video conferencing..."); }
  writeReport(): void { console.log("Generating report..."); }
}

Solution: Segregated Interfaces

// Solution: Segregated interfaces
interface IWorkable {
  work(): void;
}
 
interface IFeedable {
  eat(): void;
}
 
interface ISleepable {
  sleep(): void;
}
 
interface IMeetingAttendee {
  attendMeeting(): void;
}
 
interface IReportWriter {
  writeReport(): void;
}
 
// Human implements all that apply
class HumanWorker implements IWorkable, IFeedable, ISleepable, IMeetingAttendee, IReportWriter {
  work(): void { console.log("Working..."); }
  eat(): void { console.log("Eating lunch..."); }
  sleep(): void { console.log("Sleeping..."); }
  attendMeeting(): void { console.log("In meeting..."); }
  writeReport(): void { console.log("Writing report..."); }
}
 
// Robot implements only what applies
class RobotWorker implements IWorkable, IMeetingAttendee, IReportWriter {
  work(): void { console.log("Processing tasks..."); }
  attendMeeting(): void { console.log("Video conferencing..."); }
  writeReport(): void { console.log("Generating report..."); }
}
 
// Functions require only what they need
function scheduleWork(worker: IWorkable): void {
  worker.work();
}
 
function scheduleLunch(feedable: IFeedable): void {
  feedable.eat();
}
 
// Works with both
scheduleWork(new HumanWorker());
scheduleWork(new RobotWorker());
 
// Only works with humans
scheduleLunch(new HumanWorker());
// scheduleLunch(new RobotWorker()); // Compile error!

Real-World Example: Notification System

Let's build a complete notification system using polymorphism and interfaces:

// TypeScript - Complete notification system
 
// Core interfaces
interface INotification {
  send(recipient: string, message: string): Promise<boolean>;
}
 
interface INotificationFormatter {
  format(message: string): string;
}
 
interface INotificationLogger {
  log(notification: string, success: boolean): void;
}
 
// Notification implementations
class EmailNotification implements INotification {
  async send(recipient: string, message: string): Promise<boolean> {
    console.log(`📧 Sending email to ${recipient}: ${message}`);
    // Simulate async email sending
    await new Promise(resolve => setTimeout(resolve, 100));
    return true;
  }
}
 
class SMSNotification implements INotification {
  async send(recipient: string, message: string): Promise<boolean> {
    console.log(`📱 Sending SMS to ${recipient}: ${message}`);
    await new Promise(resolve => setTimeout(resolve, 50));
    return true;
  }
}
 
class PushNotification implements INotification {
  async send(recipient: string, message: string): Promise<boolean> {
    console.log(`🔔 Sending push to ${recipient}: ${message}`);
    await new Promise(resolve => setTimeout(resolve, 30));
    return true;
  }
}
 
class SlackNotification implements INotification {
  async send(recipient: string, message: string): Promise<boolean> {
    console.log(`💬 Sending Slack to ${recipient}: ${message}`);
    await new Promise(resolve => setTimeout(resolve, 80));
    return true;
  }
}
 
// Formatter implementations
class PlainTextFormatter implements INotificationFormatter {
  format(message: string): string {
    return message;
  }
}
 
class HTMLFormatter implements INotificationFormatter {
  format(message: string): string {
    return `<html><body><p>${message}</p></body></html>`;
  }
}
 
class MarkdownFormatter implements INotificationFormatter {
  format(message: string): string {
    return `**Notification:** ${message}`;
  }
}
 
// Logger implementations
class ConsoleLogger implements INotificationLogger {
  log(notification: string, success: boolean): void {
    const status = success ? "✅" : "❌";
    console.log(`${status} [LOG] ${notification}`);
  }
}
 
class FileLogger implements INotificationLogger {
  log(notification: string, success: boolean): void {
    // In real implementation, would write to file
    console.log(`[FILE LOG] ${success ? "SUCCESS" : "FAILURE"}: ${notification}`);
  }
}
 
// Notification service using composition and polymorphism
class NotificationService {
  private notifications: INotification[] = [];
  private formatter: INotificationFormatter;
  private logger: INotificationLogger;
 
  constructor(
    formatter: INotificationFormatter = new PlainTextFormatter(),
    logger: INotificationLogger = new ConsoleLogger()
  ) {
    this.formatter = formatter;
    this.logger = logger;
  }
 
  addNotificationChannel(notification: INotification): void {
    this.notifications.push(notification);
  }
 
  removeNotificationChannel(notification: INotification): void {
    const index = this.notifications.indexOf(notification);
    if (index > -1) {
      this.notifications.splice(index, 1);
    }
  }
 
  setFormatter(formatter: INotificationFormatter): void {
    this.formatter = formatter;
  }
 
  setLogger(logger: INotificationLogger): void {
    this.logger = logger;
  }
 
  async notify(recipient: string, message: string): Promise<void> {
    const formattedMessage = this.formatter.format(message);
 
    for (const notification of this.notifications) {
      try {
        const success = await notification.send(recipient, formattedMessage);
        this.logger.log(
          `Sent to ${recipient}: ${message.substring(0, 50)}...`,
          success
        );
      } catch (error) {
        this.logger.log(
          `Failed to send to ${recipient}: ${error}`,
          false
        );
      }
    }
  }
 
  async notifyAll(recipients: string[], message: string): Promise<void> {
    for (const recipient of recipients) {
      await this.notify(recipient, message);
    }
  }
}
 
// Usage
async function main() {
  // Create service with custom formatter and logger
  const service = new NotificationService(
    new MarkdownFormatter(),
    new ConsoleLogger()
  );
 
  // Add notification channels (polymorphic)
  service.addNotificationChannel(new EmailNotification());
  service.addNotificationChannel(new SMSNotification());
  service.addNotificationChannel(new PushNotification());
  service.addNotificationChannel(new SlackNotification());
 
  // Send notifications
  await service.notify("user@example.com", "Your order has been shipped!");
 
  // Change formatter at runtime
  service.setFormatter(new HTMLFormatter());
  await service.notify("admin@example.com", "New user signup");
}
 
main();
# Python - Complete notification system
from abc import ABC, abstractmethod
import asyncio
 
# Core interfaces
class INotification(ABC):
    @abstractmethod
    async def send(self, recipient: str, message: str) -> bool:
        pass
 
class INotificationFormatter(ABC):
    @abstractmethod
    def format(self, message: str) -> str:
        pass
 
class INotificationLogger(ABC):
    @abstractmethod
    def log(self, notification: str, success: bool) -> None:
        pass
 
# Notification implementations
class EmailNotification(INotification):
    async def send(self, recipient: str, message: str) -> bool:
        print(f"📧 Sending email to {recipient}: {message}")
        await asyncio.sleep(0.1)
        return True
 
class SMSNotification(INotification):
    async def send(self, recipient: str, message: str) -> bool:
        print(f"📱 Sending SMS to {recipient}: {message}")
        await asyncio.sleep(0.05)
        return True
 
class PushNotification(INotification):
    async def send(self, recipient: str, message: str) -> bool:
        print(f"🔔 Sending push to {recipient}: {message}")
        await asyncio.sleep(0.03)
        return True
 
# Formatter implementations
class PlainTextFormatter(INotificationFormatter):
    def format(self, message: str) -> str:
        return message
 
class HTMLFormatter(INotificationFormatter):
    def format(self, message: str) -> str:
        return f"<html><body><p>{message}</p></body></html>"
 
# Logger implementations
class ConsoleLogger(INotificationLogger):
    def log(self, notification: str, success: bool) -> None:
        status = "✅" if success else "❌"
        print(f"{status} [LOG] {notification}")
 
# Notification service
class NotificationService:
    def __init__(
        self,
        formatter: INotificationFormatter = None,
        logger: INotificationLogger = None
    ):
        self._notifications: list[INotification] = []
        self._formatter = formatter or PlainTextFormatter()
        self._logger = logger or ConsoleLogger()
 
    def add_notification_channel(self, notification: INotification) -> None:
        self._notifications.append(notification)
 
    def set_formatter(self, formatter: INotificationFormatter) -> None:
        self._formatter = formatter
 
    async def notify(self, recipient: str, message: str) -> None:
        formatted_message = self._formatter.format(message)
 
        for notification in self._notifications:
            try:
                success = await notification.send(recipient, formatted_message)
                self._logger.log(
                    f"Sent to {recipient}: {message[:50]}...",
                    success
                )
            except Exception as e:
                self._logger.log(
                    f"Failed to send to {recipient}: {e}",
                    False
                )
 
# Usage
async def main():
    service = NotificationService(
        formatter=PlainTextFormatter(),
        logger=ConsoleLogger()
    )
 
    service.add_notification_channel(EmailNotification())
    service.add_notification_channel(SMSNotification())
    service.add_notification_channel(PushNotification())
 
    await service.notify("user@example.com", "Your order has been shipped!")
 
    # Change formatter at runtime
    service.set_formatter(HTMLFormatter())
    await service.notify("admin@example.com", "New user signup")
 
asyncio.run(main())

Summary and Key Takeaways

Polymorphism

  • Allows objects to take "many forms"
  • Same interface, different implementations
  • Enables flexible, extensible code

Two Types

Compile-Time (Static)Runtime (Dynamic)
Method overloadingMethod overriding
Resolved at compile timeResolved at runtime
Based on parametersBased on actual object type
Same classParent-child classes

Interfaces

  • Define contracts without implementation
  • Enable multiple inheritance of types
  • Foundation for dependency injection
  • Allow swapping implementations at runtime

Duck Typing

  • Object compatibility based on structure, not type
  • "If it walks like a duck and quacks like a duck..."
  • More flexible but less safe
  • Common in Python, supported in TypeScript

Liskov Substitution Principle

  • Subclasses must be substitutable for parent classes
  • Avoid violating parent class contracts
  • Square/Rectangle is a classic violation example
  • Prefer composition when inheritance doesn't fit

Best Practices

  1. Program to interfaces, not implementations
  2. Prefer composition over inheritance for flexibility
  3. Keep interfaces small (Interface Segregation)
  4. Respect LSP — subtypes must honor parent contracts
  5. Use polymorphism to eliminate conditional logic

Practice Exercises

  1. Build a Plugin System: Create a plugin architecture where plugins implement an IPlugin interface with init(), execute(), and cleanup() methods. The main application should be able to load and run any plugin polymorphically.

  2. Implement a Sorting Framework: Design a sorting framework with a ISortable interface. Implement bubble sort, quick sort, and merge sort. Create a SortingService that can use any sorting algorithm.

  3. Create a Media Player: Build a media player that can play different media types (Audio, Video, Stream) through a common IPlayable interface. Add support for playlists and different playback strategies.


What's Next?

Now that you understand polymorphism and interfaces, you're ready to dive into the SOLID Principles in the next post. You'll learn:

  • Single Responsibility Principle (SRP)
  • Open/Closed Principle (OCP)
  • Liskov Substitution Principle (LSP) in depth
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

Continue your OOP journey: SOLID Principles Explained


Additional Resources

Previous Posts in This Series

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.