Composition vs Inheritance
Inheritance and composition are two ways to reuse behavior across classes. Inheritance models an is-a relationship by extending a base class; composition models a has-a relationship by assembling objects from smaller collaborators. Both are valid, but deep inheritance hierarchies grow rigid and fragile, while composition stays flexible and testable. This page contrasts the two with concrete TypeScript examples and shows why “favor composition over inheritance” is enduring advice — without claiming inheritance is never appropriate.
How inheritance works
A subclass extends a base class, inheriting its fields and methods and optionally overriding them. It is a tight, compile-time coupling: the subclass is bound to its parent’s structure.
class Animal {
constructor(protected readonly name: string) {}
move(): string {
return `${this.name} moves`;
}
}
class Dog extends Animal {
override move(): string {
return `${this.name} runs`;
}
bark(): string {
return "Woof";
}
}
This works well for a shallow, genuine is-a relationship. The override keyword (with noImplicitOverride enabled) documents intent and catches typos where you think you are overriding but are not.
Where inheritance breaks down
Problems appear when hierarchies deepen or behaviors cut across the tree. Suppose some animals can swim and some can fly. A single inheritance chain forces awkward choices, leading to duplicated code or empty overrides.
// ❌ Rigid: behaviors don't fit a single line of descent
class Bird extends Animal {
fly() {
return "flying";
}
}
class Duck extends Bird {
// a Duck also swims — but swimming lives on no shared ancestor,
// so we either duplicate swim() or invent a contrived base class
swim() {
return "swimming";
}
}
Swimming and flying are independent capabilities. Cramming them into one chain causes the classic problems: duplicated logic, the fragile base class (a change upstream silently breaks subclasses), and combinatorial explosion as variants multiply.
Composing behavior instead
Composition builds objects from focused behavior pieces. Define each capability as its own small unit, then assemble a class from the ones it needs. Capabilities combine freely without a hierarchy.
interface Swimmer {
swim(): string;
}
interface Flyer {
fly(): string;
}
const canSwim = (): Swimmer => ({ swim: () => "swimming" });
const canFly = (): Flyer => ({ fly: () => "flying" });
class Duck implements Swimmer, Flyer {
private readonly swimmer = canSwim();
private readonly flyer = canFly();
swim() {
return this.swimmer.swim();
}
fly() {
return this.flyer.fly();
}
}
A Duck now has swimming and flying behaviors. A Penguin could compose canSwim() alone. Each capability is defined once, tested once, and mixed into any class that needs it — no shared ancestor required.
Delegation with injected collaborators
Composition pairs naturally with dependency injection: pass collaborators in rather than hard-coding subclasses. This keeps the class open to new behavior at runtime.
interface PaymentMethod {
pay(amount: number): string;
}
class CreditCard implements PaymentMethod {
pay(amount: number) {
return `Charged $${amount} to card`;
}
}
class Paypal implements PaymentMethod {
pay(amount: number) {
return `Paid $${amount} via PayPal`;
}
}
class Checkout {
constructor(private readonly method: PaymentMethod) {}
complete(amount: number): string {
return this.method.pay(amount);
}
}
new Checkout(new CreditCard()).complete(50);
new Checkout(new Paypal()).complete(50);
Checkout gains new payment behaviors without subclassing — you inject a different PaymentMethod. With inheritance you would need a CreditCardCheckout and PaypalCheckout subclass for every combination, which does not scale.
Choosing between them
Inheritance is not forbidden; it shines for true is-a relationships with a stable base, such as framework base classes or abstract templates. Composition wins when behaviors are orthogonal, when you need runtime flexibility, or when the hierarchy would otherwise grow deep.
| Aspect | Inheritance | Composition |
|---|---|---|
| Relationship | is-a | has-a / uses-a |
| Coupling | Tight, compile-time | Loose, swappable |
| Reuse across branches | Hard (single chain) | Easy (mix capabilities) |
| Runtime flexibility | Low | High (inject collaborators) |
| Testability | Base class drags in | Mock individual parts |
| Best for | Stable is-a taxonomies | Cross-cutting behaviors |
“Favor composition over inheritance” is a default, not an absolute. Use inheritance for a genuine is-a with a shallow, stable hierarchy; reach for composition the moment you find yourself fighting the tree.
Best Practices
- Model is-a relationships with inheritance and has-a relationships with composition.
- Keep inheritance hierarchies shallow; deep chains signal a need to compose instead.
- Define behaviors as small interfaces and compose the ones a class actually needs.
- Inject collaborators through the constructor so behavior can vary at runtime.
- Use
overridewithnoImplicitOverrideto make intentional overrides explicit and safe. - Watch for the fragile base class smell — if changing a parent breaks children, prefer composition.
- Avoid forcing unrelated capabilities into one chain; combine independent pieces instead.