What is composition over inheritance?
Composition over inheritance is a design principle that advocates for building complex objects by combining simpler objects or functionalities (composition), rather than extending the functionality of existing classes through inheritance. In JavaScript, this principle is particularly powerful due to its flexible object model and functional programming capabilities.
What is Composition Over Inheritance?
The core idea behind composition over inheritance is to achieve code reuse and polymorphism by assembling objects from smaller, independent components that provide specific behaviors. Instead of a child class inheriting methods and properties from a parent class, an object 'has-a' relationship with other objects, each contributing a piece of the overall functionality.
In contrast, traditional inheritance establishes an 'is-a' relationship. For example, a 'Dog is-a Animal'. While useful for strict hierarchies, inheritance can lead to rigid designs, tight coupling, and the 'Liskov Substitution Principle' violations if not carefully managed. It can also make systems harder to extend and maintain as hierarchies grow deep.
Why Composition?
- Flexibility: Objects can be composed with different combinations of behaviors at runtime, making them more adaptable to changing requirements.
- Reusability: Smaller, focused components (often called 'traits', 'mixins', or 'behaviors') are easier to reuse across different parts of an application without carrying unwanted baggage from a parent class.
- Loose Coupling: Components are independent, reducing dependencies and making the system easier to understand, test, and maintain.
- Avoids the 'Diamond Problem': Unlike multiple inheritance, which can lead to ambiguity regarding method resolution (e.g., C++), composition avoids this issue by explicitly assigning behaviors.
- Better Testability: Individual components can be tested in isolation more easily.
How to Implement Composition in JavaScript?
JavaScript's dynamic nature makes composition straightforward. Common patterns include creating factory functions that assemble objects from smaller 'mixin' objects or functions, using Object.assign(), spread syntax (...), or higher-order functions to combine behaviors.
Example: Mixins/Function Factories
const canWalk = ({ speed = 1 } = {}) => ({
walk: () => console.log(`Walking at ${speed} km/h`)
});
const canSwim = ({ depth = 10 } = {}) => ({
swim: () => console.log(`Swimming at ${depth} meters`)
});
const canFly = ({ altitude = 100 } = {}) => ({
fly: () => console.log(`Flying at ${altitude} meters`)
});
// A factory function that composes behaviors
const createDuck = ({ name, speed, depth, altitude } = {}) => ({
name,
...canWalk({ speed }),
...canSwim({ depth }),
...canFly({ altitude })
});
const donald = createDuck({ name: "Donald", speed: 5, depth: 50, altitude: 200 });
donald.walk(); // Output: Walking at 5 km/h
donald.swim(); // Output: Swimming at 50 meters
donald.fly(); // Output: Flying at 200 meters
const penguin = { name: "Skipper", ...canWalk({ speed: 2 }), ...canSwim({ depth: 70 }) };
penguin.walk(); // Output: Walking at 2 km/h
penguin.swim(); // Output: Swimming at 70 meters
// penguin.fly(); // Error: penguin.fly is not a function
In this example, canWalk, canSwim, and canFly are small functions (mixins) that return objects with specific behaviors. The createDuck factory function then composes these behaviors into a single object using the spread syntax (...). This allows us to create objects with a flexible set of capabilities without relying on a rigid class hierarchy. A penguin object can be composed with canWalk and canSwim without having canFly.
When to Use Which?
While composition is often preferred, inheritance still has its place, especially for clear, natural 'is-a' relationships where the base class provides a fundamental, non-changing set of behaviors that all subclasses share. For instance, a Button 'is-a' UIElement. However, when dealing with complex, orthogonal behaviors (e.g., an object that needs to be loggable, serializable, and auditable), composition provides a more flexible and maintainable solution.
Conclusion
Composition over inheritance encourages a modular and flexible approach to object design in JavaScript. By combining smaller, focused functionalities, developers can create more robust, reusable, and testable code that is easier to adapt and maintain over time. It's a powerful principle that aligns well with modern JavaScript's emphasis on functional programming and flexible object creation.