Skip to content

Instantly share code, notes, and snippets.

@GridexX
Created April 4, 2025 08:34
Show Gist options
  • Save GridexX/ab607a608f5bd29acefd787a469b1b0a to your computer and use it in GitHub Desktop.
Save GridexX/ab607a608f5bd29acefd787a469b1b0a to your computer and use it in GitHub Desktop.
Use Polymorphism instead of switch statement: Factory Pattern with TypeScript

Use Polymorphism instead of switch statement: Registry Pattern with TypeScript

Purpose of the File

This file demonstrates how to replace a switch statement with polymorphism, one of the core principles of Object-Oriented Programming (OOP). The goal is to improve code readability, maintainability, and extensibility by avoiding the pitfalls of switch statements.

Problem Solved

Why Avoid switch Statements 🤔?

  1. Long and Complex Methods: switch statements often lead to lengthy methods with dozens of conditions, making the code harder to read and maintain.
  2. Lack of Extensibility: Adding new cases to a switch statement requires modifying the existing code, which violates the Open/Closed Principle (OCP) of software design.
  3. Mixed Logic: A single switch statement can mix unrelated logic, violating the Single Responsibility Principle (SRP).
  4. Unreadable Code: Sifting through hundreds of conditionals is not user-friendly and can confuse other developers.

The Solution: Polymorphism

By leveraging polymorphism, we can eliminate the need for switch statements. Each case in the switch is replaced with a class that encapsulates the behavior for that specific case. This approach adheres to clean code principles and makes the code easier to extend and maintain.

Example usage: Using Polymorphism in TypeScript with Factory Pattern

Imagine you have a scenario where you need to create different types of fruit objects based on user input or some other condition.

Here what would the initial code look like using a switch statement:

enum FruitType {
  Apple = 'Apple',
  Banana = 'Banana',
  Lemon = 'Lemon',
  Orange = 'Orange',
}

function getPrice(fruit: FruitType): number {
  let result: number;

  switch (fruit) {
    case Fruits.Banana:
      result = 11.1;
      break;
    case Fruits.Lemon:
      result = 6.5;
      break;
    case Fruits.Orange:
      result = 7.7;
      break;
    case Fruits.Apple:
      result = 5.2;
      break;
    default:
      result = 0;
  }

  return result;
}

As you can see, the switch statement is long and complex. It also violates the Open/Closed Principle because adding a new fruit type would require modifying the existing code. Instead, we can use polymorphism to create a cleaner and more maintainable solution.

How It Works

We define an abstract Fruit class that encapsulates the shared behavior of all fruits. Each specific fruit (e.g., Apple, Banana, Lemon, Orange) is implemented as a subclass of Fruit, overriding the necessary properties or methods.

abstract class Fruit implements Nullable {
  protected constructor(readonly price: number) {}
}

We can now leverage the Registry Pattern to create instances of the specific fruit classes based on user input or other conditions. This allows us to avoid the switch statement entirely.

export const Fruits: Record<FruitType, new () => Fruit> = {
  Apple: class extends Fruit {
    constructor() {
      super(5.2);
    }
  },
  Banana: class extends Fruit {
    constructor() {
      super(11.1);
    }
  },
  Lemon: class extends Fruit {
    constructor() {
      super(6.5);
    }
  },
  Orange: class extends Fruit {
    constructor() {
      super(7.7);
    }
  },
};

function findFruitConstructor(
  constructorName: string | FruitType
): new () => Fruit {
  const fruitType = Object.values(FruitType).find(
    (type) => type === constructorName
  ) as FruitType | undefined;

  return fruitType ? Fruits[fruitType] : NullableFruit;
}

Instead of using a conditional statement to determine the behavior based on the fruit type, we use a findFruitConstructor function to dynamically retrieve the appropriate class constructor. This allows us to create instances of the desired fruit without hardcoding logic.

Usage Code

const Banana = findFruitConstructor('Banana');
const banana = new Banana();
console.log(banana); // { "price": 11.1 }
console.log(banana.isNull()); // false

// Won't throw an error
const NotExisting = findFruitConstructor('NotExisting');
const notExistingFruit = new NotExisting();
console.log(notExistingFruit); // { "price": 0 }
console.log(notExistingFruit.isNull()); // true

Summary

This pattern combines several design patterns and TypeScript features. It's a combination of:

  1. Registry Pattern - The Fruits object acts as a registry that maps fruit types to their constructor functions.

  2. Null Object Pattern - The NullableFruit class represents a "null" implementation of the Fruit abstraction, allowing you to handle missing fruits without throwing errors or checking for null/undefined.

  3. Factory Method Pattern - The findFruitConstructor function acts as a factory that retrieves the appropriate constructor based on a string identifier.

The overall approach might be referred to as a "Type-Safe Registry with Null Object" pattern. It allows you to:

  • Maintain a strongly-typed registry of constructors
  • Look up constructors by string identifier
  • Handle missing values gracefully with a default implementation
  • Provide a consistent interface across all implementations
  • Check if an object is a "null" implementation using the isNull() method

This is a particularly TypeScript-oriented implementation that leverages TypeScript's enum system, class inheritance, and the Record utility type to create a robust type-safe registry with built-in null handling.

References

Here is the full code 👇

# This file was heavily inspired by
# https://obaranovskyi.medium.com/typescript-use-polymorphism-in-place-of-the-switch-and-other-conditionals-1cfcc705bcc1
enum FruitType {
Apple = 'Apple',
Banana = 'Banana',
Lemon = 'Lemon',
Orange = 'Orange',
}
interface Nullable {
isNull(): boolean;
}
abstract class Fruit implements Nullable {
protected constructor(readonly price: number) {}
isNull(): boolean {
return false;
}
}
class NullableFruit extends Fruit {
constructor() {
super(0);
}
isNull(): boolean {
return true;
}
}
export const Fruits: Record<FruitType, new () => Fruit> = {
Apple: class extends Fruit {
constructor() {
super(5.2);
}
},
Banana: class extends Fruit {
constructor() {
super(11.1);
}
},
Lemon: class extends Fruit {
constructor() {
super(6.5);
}
},
Orange: class extends Fruit {
constructor() {
super(7.7);
}
},
};
function findFruitConstructor(
constructorName: string | FruitType
): new () => Fruit {
const fruitType = Object.values(FruitType).find(
(type) => type === constructorName
) as FruitType | undefined;
return fruitType ? Fruits[fruitType] : NullableFruit;
}
const Banana = findFruitConstructor('Banana');
const banana = new Banana();
console.log(banana); // { "price": 11.1 }
console.log(banana.isNull()); // false
// Won't throw an error
const NotExisting = findFruitConstructor('NotExisting');
const notExistingFruit = new NotExisting();
console.log(notExistingFruit); // { "price": 0 }
console.log(notExistingFruit.isNull()); // true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment