Composition over Inheritance in TypeScript

Table of Contents

  • Introduction
  • Understanding Inheritance and Composition
  • Why Prefer Composition Over Inheritance?
  • Composition in TypeScript
    • Using Interfaces and Classes for Composition
    • Composition Example: Combining Behaviors
    • Benefits of Composition
  • When to Use Inheritance
  • Conclusion

Introduction

In object-oriented programming, inheritance has traditionally been the go-to design pattern for code reuse and creating relationships between classes. However, over time, developers have recognized the limitations and drawbacks of inheritance, such as tight coupling and limited flexibility. This has led to the adoption of an alternative design principle known as Composition over Inheritance.

In this article, we will explore the concept of composition, why it’s often preferred over inheritance in TypeScript, and how you can implement composition in TypeScript. We’ll also discuss situations where inheritance might still be appropriate, and how to make the best choice depending on your project’s requirements.


Understanding Inheritance and Composition

Inheritance

Inheritance allows a class (child class) to inherit properties and methods from another class (parent class). It promotes reusability and can help establish relationships between entities. However, inheritance comes with certain drawbacks:

  • Tight Coupling: Child classes are tightly coupled to their parent classes, making changes in the parent class potentially break the child class.
  • Limited Flexibility: A child class can inherit only from one parent class, limiting how reusable and flexible the design can be.
  • Overriding Issues: When extending a class, you may end up overriding methods that might not be ideal for the specific use case of the child class.

Composition

Composition, on the other hand, involves creating more flexible, reusable components by combining simple objects or classes to achieve desired behaviors. Instead of extending a parent class, a class or object can include (compose) instances of other classes or interfaces that provide additional functionality.

Composition offers several advantages:

  • Loose Coupling: Components can be changed or replaced independently.
  • Flexibility: Different behaviors can be composed by mixing different components together.
  • Avoids Inheritance Chains: Instead of deep inheritance hierarchies, components can be reused directly, leading to better scalability.

Why Prefer Composition Over Inheritance?

While inheritance can be useful in certain scenarios, it often leads to rigid and less flexible code, especially in large applications. Below are some of the key reasons why composition is often preferred over inheritance:

1. Loose Coupling

With inheritance, subclasses are tightly coupled to their parent classes. If the parent class changes, it may require changes to all the child classes, even if they don’t require those changes. Composition, on the other hand, allows objects to interact with each other without tightly binding their implementations.

2. Avoiding Deep Inheritance Hierarchies

In larger applications, inheritance can lead to deep and complicated class hierarchies, making code hard to understand, extend, and maintain. With composition, you can avoid such complexities by composing objects from simpler building blocks.

3. Reusability and Flexibility

Inheritance allows reusability only in one direction (from parent to child). Composition allows more flexibility by enabling objects to share behaviors dynamically. You can mix and match different components and behaviors without affecting other parts of the codebase.

4. Encapsulation

In composition, each component can be independently developed, tested, and maintained. This leads to better encapsulation of functionality. In contrast, inheritance exposes inherited properties and methods, which may not be relevant to every subclass.


Composition in TypeScript

Composition in TypeScript typically involves using interfaces and classes to combine functionalities. Here are some approaches to implementing composition.

Using Interfaces and Classes for Composition

You can use interfaces to define reusable behavior and then compose these interfaces into a class. This approach allows a class to exhibit multiple behaviors without having to inherit from a single parent class.

Example: Using Composition for Combining Behaviors

Let’s create a scenario where a Car class needs to combine the behaviors of both Driveable and Flyable objects.

// Define two interfaces with different behaviors
interface Driveable {
drive(): void;
}

interface Flyable {
fly(): void;
}

// Create concrete implementations of the interfaces
class Car implements Driveable {
drive(): void {
console.log("Car is driving");
}
}

class Plane implements Flyable {
fly(): void {
console.log("Plane is flying");
}
}

// Create a class that uses composition to combine behaviors
class FlyingCar implements Driveable, Flyable {
private car: Car;
private plane: Plane;

constructor() {
this.car = new Car();
this.plane = new Plane();
}

drive(): void {
this.car.drive(); // Delegate to the car's drive method
}

fly(): void {
this.plane.fly(); // Delegate to the plane's fly method
}
}

// Usage of the composed class
const flyingCar = new FlyingCar();
flyingCar.drive(); // Output: Car is driving
flyingCar.fly(); // Output: Plane is flying

In this example:

  • The FlyingCar class doesn’t need to inherit from Car or Plane. Instead, it composes these objects to delegate the functionality.
  • This allows for more flexibility as you can easily swap out or modify the Car or Plane behaviors without affecting the FlyingCar class itself.

Composition Example: Composing Object Behaviors

Composition can also be used when you want to compose behaviors using simple objects.

// Define behaviors using functions
const canDrive = {
drive: () => console.log("Can drive!"),
};

const canFly = {
fly: () => console.log("Can fly!"),
};

// Compose objects with different behaviors
class FlyingCar {
private behaviors: any;

constructor() {
this.behaviors = Object.assign({}, canDrive, canFly);
}

drive(): void {
this.behaviors.drive();
}

fly(): void {
this.behaviors.fly();
}
}

// Usage
const flyingCar = new FlyingCar();
flyingCar.drive(); // Output: Can drive!
flyingCar.fly(); // Output: Can fly!

Here:

  • Instead of creating a separate Car and Plane object, we directly compose behaviors using simple objects that provide the drive and fly methods.
  • This allows more dynamic composition and flexibility.

Benefits of Composition

1. Separation of Concerns

Composition allows you to separate different concerns and encapsulate them in distinct components. This makes your code more modular and easier to maintain. For example, you can change the behavior of driving or flying without modifying the entire class structure.

2. Greater Flexibility

With composition, you can mix and match components that implement different behaviors. You’re not restricted to a rigid inheritance structure and can easily add new behaviors or replace existing ones as your system evolves.

3. Better Testability

Composition makes unit testing easier because each component can be tested independently. Unlike inheritance, where changes in the parent class can ripple through the entire class hierarchy, composed components are isolated and can be tested independently.

4. Avoiding Inheritance Pitfalls

Composition sidesteps many issues that come with inheritance, such as the diamond problem and deep inheritance trees. Instead of relying on complex relationships, composition gives you more control over how behaviors are combined.


When to Use Inheritance

While composition is often preferred, there are still situations where inheritance might be more appropriate:

  • When a clear “is-a” relationship exists: Inheritance works best when there’s a clear hierarchical relationship between classes. For example, a Dog class can inherit from an Animal class because a dog is an animal.
  • When you need to override or extend a base class’s functionality: If you have a base class that provides a lot of useful behavior and you need to customize it, inheritance is a natural choice.

In general, you should prefer composition when the relationship between components is “has-a” rather than “is-a.”


Conclusion

Composition over inheritance is a powerful design principle that leads to more maintainable, flexible, and scalable code. In TypeScript, you can leverage interfaces and classes to compose behaviors dynamically, avoiding the limitations of deep inheritance hierarchies. Composition encourages loose coupling, better testability, and greater flexibility when adding new behaviors or changing existing ones.

While inheritance is still a valid tool in certain scenarios, composition is often the better choice for building complex systems that need to be flexible and adaptable to change.