Back to blog
Diagram comparing inheritance and composition in TypeScript. On the left, Notifier, EmailNotifier and RetryingEmailNotifier in a class hierarchy. On the right, EmailSender, SmsSender and RetryingSender implementing the MessageSender interface, with RetryingSender wrapping another sender.
clean-code

Why I Stopped Reaching for extends in TypeScript

Last year I spent way too much time untangling a notification system which had grown into a small monster. The fix was an old idea I had nodded along to for years: composition over inheritance.

It started innocently. We had an EmailNotifier class, then an SmsNotifier, both extending a shared Notifier base class. Clean enough.

Then someone asked for retry logic on emails. After that came push notifications. A few weeks later, push needed retries too, but only for one customer tier. By the third month, every new feature came with the same question. Which branch of the hierarchy breaks if I touch it?

Sound familiar? If you write TypeScript or any object-oriented code, you have probably lived some version of this story. The fix is simple once it clicks. Stop modeling relationships as “is-a” and start modeling them as “has-a”.

Colorful Lego bricks snapped together, a simple picture of composition over inheritance in TypeScript

What composition over inheritance actually means

This is not a new idea. The Wikipedia page on it traces it back to classic object-oriented design literature. Still, it took me years as a working engineer to really understand why it matters.

Deep inheritance chains lock you into a rigid taxonomy too early. You are betting that “a RetryingEmailNotifier is a kind of EmailNotifier” will still make sense six months later. Then someone needs a RetryingSmsNotifier and a RetryingPushNotifier. Now you have a wall of near-duplicate classes.

Composition flips the question. Instead of asking “what is this a subtype of?”, you ask “what behavior does it need, and can I plug that in?”

A basic example, because abstract explanations never stuck for me

Here is the inheritance version of my notification mess, simplified:

abstract class Notifier {
  abstract send(message: string): Promise<void>;
}

class EmailNotifier extends Notifier {
  async send(message: string): Promise<void> {
    console.log(`Emailing: ${message}`);
  }
}

class RetryingEmailNotifier extends EmailNotifier {
  async send(message: string): Promise<void> {
    for (let attempt = 1; attempt <= 3; attempt++) {
      try {
        return await super.send(message);
      } catch (error) {
        if (attempt === 3) throw error;
      }
    }
  }
}

Looks fine until SMS needs retries too. Do you write RetryingSmsNotifier and copy-paste that loop? Or do you push retry logic into the base class and hope nobody needs a different flavor? Every option makes the tree more tangled, not less.

Now here is the same idea using composition over inheritance:

interface MessageSender {
  send(message: string): Promise<void>;
}

class EmailSender implements MessageSender {
  async send(message: string): Promise<void> {
    console.log(`Emailing: ${message}`);
  }
}

class SmsSender implements MessageSender {
  async send(message: string): Promise<void> {
    console.log(`Texting: ${message}`);
  }
}

class RetryingSender implements MessageSender {
  constructor(private inner: MessageSender, private maxRetries = 3) {}

  async send(message: string): Promise<void> {
    for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
      try {
        await this.inner.send(message);
        return;
      } catch (error) {
        if (attempt === this.maxRetries) throw error;
        console.log(`Attempt ${attempt} failed, retrying...`);
      }
    }
  }
}

// Mix and match at will:
const reliableEmail = new RetryingSender(new EmailSender());
const reliableSms = new RetryingSender(new SmsSender(), 5);

Notice what changed. RetryingSender does not care what it wraps. It only needs something that satisfies MessageSender. Want retries on push notifications tomorrow? Write a PushSender and hand it over. No new subclass, no touching existing code, and no risk of breaking email while you fix SMS.

If you read my post on the Service-Repository pattern in Laravel, this will feel familiar. Injecting a repository into a service is the same trick in a different language.

Why composition over inheritance pays off

A few things come almost for free. Testing gets easier, because you can pass RetryingSender a fake sender. You don’t need a whole inheritance chain just to isolate one behavior.

Changes also stay local. You avoid the “fragile base class” problem, where editing a parent class breaks three subclasses you forgot existed.

And the code reads closer to how you would explain it out loud. “A retrying sender wraps a sender” is much easier to follow than a three-level class chain.

So is inheritance always wrong? Not at all. For a stable, shallow hierarchy, like simple UI component variants, inheritance is fine and often simpler. Trouble starts when you are several levels deep and every requirement asks “which class does this even go in?”

The takeaway

Next time you reach for extends because two classes share some behavior, pause for a second. Could that behavior be its own small class, passed in as a dependency? It is a small shift in thinking, but it changed how I structure almost everything.

This idea sits right next to functional programming, where you compose small pure functions instead of building object hierarchies at all. I wrote about that in the functional programming habits that actually make your TypeScript better, so check it out if this one clicked for you.

Now I’m curious. Where have you seen inheritance actually hold up well in a real codebase? Tell me in the comments.

Happy Building…