Skip to main content

04 - Polymorphism: Flexibility through Interfaces

Master polymorphism in OOP: subtype polymorphism, method overriding, multiple interfaces, duck typing and generics in TypeScript.

What do you think about this article?

At a Glance

Master polymorphism in OOP: subtype polymorphism, method overriding, multiple interfaces, duck typing and generics in TypeScript.

Polymorphism: One Interface, Many Behaviors

Polymorphism (from Greek "many forms") is the ability of objects from different classes to respond to the same message in different ways. It's one of the pillars of OOP that enables writing flexible, extensible, and maintainable code. With polymorphism, we write code that works with abstract interfaces, not concrete implementations.

💡 Key Concepts

  • Subtype polymorphism: Treat objects of derived classes as objects of the base class
  • Method overriding: Redefine behaviors in subclasses
  • Duck typing: "If it walks like a duck and quacks like a duck, then it's a duck"
  • Interfaces: Contracts that guarantee common behaviors

Subtype Polymorphism

Subtype polymorphism allows using an object of a derived class wherever an object of the base class is expected. This is made possible through inheritance.

TypeScript - Basic Polymorphism
class Animal {{ '{' }}
  constructor(protected name: string) {{ '{' }}{{ '}' }}

  makeSound(): void {{ '{' }}
    console.log(`${{ '{' }}this.name{{ '}' }} makes a generic sound`);
  {{ '}' }}
{{ '}' }}

class Dog extends Animal {{ '{' }}
  // Override makeSound method
  makeSound(): void {{ '{' }}
    console.log(`${{ '{' }}this.name{{ '}' }} barks: Woof!`);
  {{ '}' }}
{{ '}' }}

class Cat extends Animal {{ '{' }}
  makeSound(): void {{ '{' }}
    console.log(`${{ '{' }}this.name{{ '}' }} meows: Meow!`);
  {{ '}' }}
{{ '}' }}

// Polymorphism in action: same method, different behaviors
function playWithAnimal(animal: Animal): void {{ '{' }}
  animal.makeSound(); // We don't know which animal it is!
{{ '}' }}

const animals: Animal[] = [
  new Dog("Rex"),
  new Cat("Whiskers"),
  new Dog("Buddy")
];

animals.forEach(animal => playWithAnimal(animal));
// Output:
// Rex barks: Woof!
// Whiskers meows: Meow!
// Buddy barks: Woof!

The playWithAnimal method accepts an Animal, but works correctly with Dog and Cat because they are subtypes of Animal. The correct method is called at runtime based on the actual type of the object.

Polymorphism with Interfaces

Interfaces define contracts that classes must respect. Completely different classes can implement the same interface, enabling polymorphism without inheritance.

TypeScript - Polymorphic Interfaces
interface Drawable {{ '{' }}
  draw(): void;
  getArea(): number;
{{ '}' }}

class Circle implements Drawable {{ '{' }}
  constructor(private radius: number) {{ '{' }}{{ '}' }}

  draw(): void {{ '{' }}
    console.log(`Drawing a circle with radius ${{ '{' }}this.radius{{ '}' }}`);
  {{ '}' }}

  getArea(): number {{ '{' }}
    return Math.PI * this.radius ** 2;
  {{ '}' }}
{{ '}' }}

class Square implements Drawable {{ '{' }}
  constructor(private side: number) {{ '{' }}{{ '}' }}

  draw(): void {{ '{' }}
    console.log(`Drawing a square with side ${{ '{' }}this.side{{ '}' }}`);
  {{ '}' }}

  getArea(): number {{ '{' }}
    return this.side ** 2;
  {{ '}' }}
{{ '}' }}

class Triangle implements Drawable {{ '{' }}
  constructor(private base: number, private height: number) {{ '{' }}{{ '}' }}

  draw(): void {{ '{' }}
    console.log(`Drawing a triangle ${{ '{' }}this.base{{ '}' }}x${{ '{' }}this.height{{ '}' }}`);
  {{ '}' }}

  getArea(): number {{ '{' }}
    return (this.base * this.height) / 2;
  {{ '}' }}
{{ '}' }}

// Polymorphic function: works with any Drawable
function renderShape(shape: Drawable): void {{ '{' }}
  shape.draw();
  console.log(`Area: ${{ '{' }}shape.getArea().toFixed(2){{ '}' }}`);
{{ '}' }}

const shapes: Drawable[] = [
  new Circle(5),
  new Square(4),
  new Triangle(6, 3)
];

shapes.forEach(renderShape);
// Output:
// Drawing a circle with radius 5
// Area: 78.54
// Drawing a square with side 4
// Area: 16.00
// Drawing a triangle 6x3
// Area: 9.00

Method Overriding and Super

Override allows subclasses to redefine inherited methods, while super enables access to the parent class implementation.

TypeScript - Override and Super
class Logger {{ '{' }}
  log(message: string): void {{ '{' }}
    console.log(`[LOG] ${{ '{' }}message{{ '}' }}`);
  {{ '}' }}
{{ '}' }}

class TimestampedLogger extends Logger {{ '{' }}
  // Override with additional functionality
  override log(message: string): void {{ '{' }}
    const timestamp = new Date().toISOString();
    // Call parent method and add timestamp
    super.log(`[${{ '{' }}timestamp{{ '}' }}] ${{ '{' }}message{{ '}' }}`);
  {{ '}' }}
{{ '}' }}

class ColoredLogger extends Logger {{ '{' }}
  override log(message: string): void {{ '{' }}
    // Completely different behavior
    console.log(`\x1b[32m[LOG] ${{ '{' }}message{{ '}' }}\x1b[0m`);
  {{ '}' }}
{{ '}' }}

function logMessage(logger: Logger, msg: string): void {{ '{' }}
  logger.log(msg);
{{ '}' }}

const basicLogger = new Logger();
const timeLogger = new TimestampedLogger();
const colorLogger = new ColoredLogger();

logMessage(basicLogger, "Basic message");
// [LOG] Basic message

logMessage(timeLogger, "Message with timestamp");
// [LOG] [2024-12-26T10:30:00.000Z] Message with timestamp

logMessage(colorLogger, "Colored message");
// [LOG] Colored message (in green)

Duck Typing in TypeScript

TypeScript uses structural typing (duck typing): if an object has the required properties and methods, it's compatible with a type, even without explicitly implementing an interface.

TypeScript - Duck Typing
interface Quackable {{ '{' }}
  quack(): void;
{{ '}' }}

class Duck {{ '{' }}
  quack(): void {{ '{' }}
    console.log("Quack!");
  {{ '}' }}
{{ '}' }}

class Person {{ '{' }}
  name: string;

  constructor(name: string) {{ '{' }}
    this.name = name;
  {{ '}' }}

  // Person has a quack method, so it's compatible with Quackable!
  quack(): void {{ '{' }}
    console.log(`${{ '{' }}this.name{{ '}' }} imitates a duck: Quack!`);
  {{ '}' }}
{{ '}' }}

// Accepts any object with quack() method
function makeItQuack(quacker: Quackable): void {{ '{' }}
  quacker.quack();
{{ '}' }}

const duck = new Duck();
const person = new Person("Mario");

makeItQuack(duck);    // "Quack!"
makeItQuack(person);  // "Mario imitates a duck: Quack!"

// Even literal objects work!
makeItQuack({{ '{' }}
  quack: () => console.log("Anonymous object: Quack!")
{{ '}' }});
// "Anonymous object: Quack!"

Parametric Polymorphism (Generics)

Generics allow writing code that works with different types while maintaining type safety. It's a form of compile-time polymorphism.

TypeScript - Polymorphic Generics
// Generic repository that works with any type
class Repository<T> {{ '{' }}
  private items: T[] = [];

  add(item: T): void {{ '{' }}
    this.items.push(item);
  {{ '}' }}

  findById(id: number): T | undefined {{ '{' }}
    return this.items[id];
  {{ '}' }}

  getAll(): T[] {{ '{' }}
    return [...this.items];
  {{ '}' }}

  filter(predicate: (item: T) => boolean): T[] {{ '{' }}
    return this.items.filter(predicate);
  {{ '}' }}
{{ '}' }}

interface User {{ '{' }}
  id: number;
  name: string;
{{ '}' }}

interface Product {{ '{' }}
  id: number;
  title: string;
  price: number;
{{ '}' }}

// Same code, different types
const userRepo = new Repository<User>();
userRepo.add({{ '{' }} id: 1, name: "Alice" {{ '}' }});
userRepo.add({{ '{' }} id: 2, name: "Bob" {{ '}' }});

const productRepo = new Repository<Product>();
productRepo.add({{ '{' }} id: 1, title: "Laptop", price: 999 {{ '}' }});
productRepo.add({{ '{' }} id: 2, title: "Mouse", price: 25 {{ '}' }});

// Type-safe: TypeScript knows that users is User[]
const users = userRepo.getAll();
const expensiveProducts = productRepo.filter(p => p.price > 500);

Polymorphism in Design Patterns

Polymorphism is fundamental in many design patterns. Let's see the Strategy Pattern:

Strategy Pattern with Polymorphism
// Common interface for all strategies
interface SortStrategy {{ '{' }}
  sort(data: number[]): number[];
{{ '}' }}

class BubbleSort implements SortStrategy {{ '{' }}
  sort(data: number[]): number[] {{ '{' }}
    console.log("Sorting with Bubble Sort");
    const arr = [...data];
    // Bubble sort implementation (simplified)
    for (let i = 0; i < arr.length; i++) {{ '{' }}
      for (let j = 0; j < arr.length - i - 1; j++) {{ '{' }}
        if (arr[j] > arr[j + 1]) {{ '{' }}
          [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
        {{ '}' }}
      {{ '}' }}
    {{ '}' }}
    return arr;
  {{ '}' }}
{{ '}' }}

class QuickSort implements SortStrategy {{ '{' }}
  sort(data: number[]): number[] {{ '{' }}
    console.log("Sorting with Quick Sort");
    // Quick sort implementation (simplified with native sort)
    return [...data].sort((a, b) => a - b);
  {{ '}' }}
{{ '}' }}

class DataSorter {{ '{' }}
  private strategy!: SortStrategy;

  // Change strategy at runtime (polymorphism!)
  setStrategy(strategy: SortStrategy): void {{ '{' }}
    this.strategy = strategy;
  {{ '}' }}

  sortData(data: number[]): number[] {{ '{' }}
    if (!this.strategy) {{ '{' }}
      throw new Error("Strategy not set");
    {{ '}' }}
    return this.strategy.sort(data);
  {{ '}' }}
{{ '}' }}

const sorter = new DataSorter();
const data = [5, 2, 8, 1, 9];

sorter.setStrategy(new BubbleSort());
console.log(sorter.sortData(data));
// "Sorting with Bubble Sort"
// [1, 2, 5, 8, 9]

sorter.setStrategy(new QuickSort());
console.log(sorter.sortData(data));
// "Sorting with Quick Sort"
// [1, 2, 5, 8, 9]

Benefits of Polymorphism

✅ Advantages

  • Extensibility: Add new classes without modifying existing code
  • Maintainability: Cleaner and more organized code
  • Reusability: Same functions for different types
  • Testability: Mocks and stubs through interfaces
  • Flexibility: Change behaviors at runtime

Conclusion

Polymorphism is the mechanism that makes OOP truly powerful. It allows us to write generic code that works with abstractions, not concrete implementations. Whether through inheritance, interfaces, duck typing, or generics, polymorphism makes software flexible, extensible, and maintainable.

🎯 Key Points

  • Polymorphism = "same interface, different behaviors"
  • Override redefines methods in subclasses
  • Interfaces enable polymorphism without inheritance
  • TypeScript uses structural typing (duck typing)
  • Generics = type-safe compile-time polymorphism

Frequently asked questions

What is polymorphism in object-oriented programming?

Polymorphism is the ability of objects from different classes to respond to the same message in different ways.

How does subtype polymorphism work?

Subtype polymorphism allows using an object of a derived class wherever an object of the base class is expected, through inheritance.

What is the purpose of interfaces in polymorphism?

Interfaces define contracts that classes must respect, enabling polymorphism without inheritance and allowing completely different classes to implement the same interface.

How does TypeScript achieve polymorphism?

TypeScript uses structural typing (duck typing), which means if an object has the required properties and methods, it's compatible with a type, even without explicitly implementing an interface.

Sources and further reading

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects