Polymorphism vs Switch Statements with examples in TypeScript

Polymorphism vs Switch Statements with examples in TypeScript

What is Polymorphism?

Polymorphism is a concept in object-oriented programming that allows objects of different classes to be treated as if they are objects of a common base class or interface. This means that you can write code that operates on objects of a base type, and then substitute those objects with objects of derived types that implement the same base type. The code that operates on the objects of the base type will automatically work with the derived objects as well because they all implement the same interface.

Why it's better than Switch Statements

Using polymorphism instead of switch statements can make your code more flexible and maintainable.

Example with Animal

Let's say we have an interface called Animal which defines a method makeSound:

interface Animal {
  makeSound(): void;
}

We also have a Zoo class which contains an array of Animal objects, and a method letAnimalsMakeSound that loops over each animal and calls its makeSound method:

class Zoo {
  animals: Animal[] = [];

  letAnimalsMakeSound() {
    for (const animal of this.animals) {
      animal.makeSound();
    }
  }
}

Now let's say we have two classes that implement the Animal interface: Dog and Cat. The makeSound method of the Dog class should output "Woof!", while the makeSound method of the Cat class should output "Meow!".

With a switch statement, we might implement the makeSound method of the Dog and Cat classes like this:

class Dog implements Animal {
  makeSound() {
    console.log("Woof!");
  }
}

class Cat implements Animal {
  makeSound() {
    console.log("Meow!");
  }
}

And we might use a switch statement in the letAnimalsMakeSound method of the Zoo class to call the makeSound method of each animal:

class Zoo {
  animals: Animal[] = [];

  letAnimalsMakeSound() {
    for (const animal of this.animals) {
      switch(animal.constructor.name) {
        case "Dog":
          (animal as Dog).makeSound();
          break;
        case "Cat":
          (animal as Cat).makeSound();
          break;
      }
    }
  }
}

This implementation works, but it has a few drawbacks:

  • It's not very flexible. If we add a new type of animal, we'll need to update the switch statement in the Zoo class.

  • It's not very maintainable. The Zoo class needs to know about all the types of animals in order to call their makeSound methods.

  • It's not very testable. We can't easily test each type of animal separately, because the Zoo class has tight coupling with all the types of animals.

On the other hand, with polymorphism, we can implement the Dog and Cat classes like this:

class Dog implements Animal {
  makeSound() {
    console.log("Woof!");
  }
}

class Cat implements Animal {
  makeSound() {
    console.log("Meow!");
  }
}

And we can simply call the makeSound method of each animal in the letAnimalsMakeSound method of the Zoo class, without needing to know the type of each animal:

class Zoo {
  animals: Animal[] = [];

  letAnimalsMakeSound() {
    for (const animal of this.animals) {
      animal.makeSound();
    }
  }
}

This implementation has several benefits:

  • It's more flexible. We can easily add new types of animals without needing to update the Zoo class.

  • It's more maintainable. The Zoo class doesn't need to know about all the types of animals in order to call their makeSound methods.

  • It's more testable. We can test each type of animal separately, because the Zoo class doesn't have tight coupling with all the types of animals.

Additionally, using polymorphism can help make our code more modular and extensible. For example, let's say we want to add a new feature to our Animal interface: the ability to move. We can add a new method move to the Animal interface and we can implement it in the Dog and Cat classes like this:

interface Animal {
  makeSound(): void;
  move(): void;
}

class Dog implements Animal {
  makeSound() {
    console.log("Woof!");
  }

  move() {
    console.log("Running!");
  }
}

class Cat implements Animal {
  makeSound() {
    console.log("Meow!");
  }

  move() {
    console.log("Jumping!");
  }
}

Now, we can call the move method of each animal in the letAnimalsMove method of the Zoo class:

class Zoo {
  animals: Animal[] = [];

  letAnimalsMakeSound() {
    for (const animal of this.animals) {
      animal.makeSound();
    }
  }

  letAnimalsMove() {
    for (const animal of this.animals) {
      animal.move();
    }
  }
}

Example with Shapes

Consider the following example code that uses switch statements to handle different types of shapes:

interface Shape {
  type: string;
  area(): number;
}

class Circle implements Shape {
  type = 'circle';
  radius: number;

  constructor(radius: number) {
    this.radius = radius;
  }

  area() {
    return Math.PI * this.radius * this.radius;
  }
}

class Rectangle implements Shape {
  type = 'rectangle';
  width: number;
  height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  area() {
    return this.width * this.height;
  }
}

function calculateArea(shape: Shape) {
  let area: number;

  switch(shape.type) {
    case 'circle':
      area = (shape as Circle).area();
      break;
    case 'rectangle':
      area = (shape as Rectangle).area();
      break;
    default:
      area = 0;
      break;
  }

  return area;
}

const myCircle = new Circle(5);
console.log(calculateArea(myCircle)); // Output: 78.53981633974483

const myRectangle = new Rectangle(10, 5);
console.log(calculateArea(myRectangle)); // Output: 50

In this code, the calculateArea function switches on the type property of the Shape interface to determine which specific method to call. However, if we add more shapes to our code in the future, we will need to modify this function to handle them.

Instead of using a switch statement, we can use polymorphism to handle different types of shapes without modifying our code. We can add a calculateArea method to the Shape interface, and then implement it in each of the derived classes:

interface Shape {
  type: string;
  calculateArea(): number;
}

class Circle implements Shape {
  type = 'circle';
  radius: number;

  constructor(radius: number) {
    this.radius = radius;
  }

  calculateArea() {
    return Math.PI * this.radius * this.radius;
  }
}

class Rectangle implements Shape {
  type = 'rectangle';
  width: number;
  height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  calculateArea() {
    return this.width * this.height;
  }
}

function printArea(shape: Shape) {
  console.log(shape.calculateArea());
}

const myCircle = new Circle(5);
printArea(myCircle); // Output: 78.53981633974483

const myRectangle = new Rectangle(10, 5);
printArea(myRectangle); // Output: 50

In this code, the printArea function takes a Shape object and calls its calculateArea method, without switching on its type property. This code will work with any object that implements the Shape interface, even if we add new shapes in the future.

In addition to making your code more flexible and maintainable, using polymorphism instead of switch statements can also make your code more testable. When you use polymorphism, you can write tests for each derived class separately, without needing to write tests that cover all possible combinations of types.

Consider the following example test code for the calculateArea function that uses a switch statement:

describe('calculateArea', () => {
  it('calculates the area of a circle', () => {
    const myCircle = new Circle(5);
    expect(calculateArea(myCircle)).toBe(78.53981633974483);
  });

  it('calculates the area of a rectangle', () => {
    const myRectangle = new Rectangle(10, 5);
    expect(calculateArea(myRectangle)).toBe(50);
  });

  it('returns 0 for an unknown shape type', () => {
    const myShape = { type: 'unknown' };
    expect(calculateArea(myShape)).toBe(0);
  });
});

In this code, we need to test all possible combinations of shape types that the calculateArea function can handle, including an unknown shape type. This can become difficult to maintain as we add more shapes to our code.

Instead of using a switch statement, we can use polymorphism to make our code more testable. We can create separate test cases for each derived class, and test the calculateArea method of each class separately:

describe('calculateArea', () => {
  it('calculates the area of a circle', () => {
    const myCircle = new Circle(5);
    expect(myCircle.calculateArea()).toBe(78.53981633974483);
  });

  it('calculates the area of a rectangle', () => {
    const myRectangle = new Rectangle(10, 5);
    expect(myRectangle.calculateArea()).toBe(50);
  });
});

In this code, we only need to test the calculateArea method for each derived class. This makes our tests more focused and easier to maintain.

Using polymorphism allows us to add new features to our code without needing to update all the classes that implement the Animal interface. We can simply add the new method to the interface, and each implementing class can choose to implement it or not.

In summary, using polymorphism instead of switch statements can help make our code more flexible, maintainable, and testable, and can also help make our code more modular and extensible.