Skip to Content
Course content

40: Abstract Classes and Interfaces

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this happen a dozen times when developers move from a language like Java or C# to TypeScript, or even when they're just trying to "dry up" their code. You start with a clean interface, you realize you're repeating the same logic in three different classes, and you try to shove that logic into the interface. Then, you hit a wall.

Take a look at this payment system I'm reviewing. The developer wanted every payment method to have a processPayment method, but they also wanted a shared logTransaction method so they wouldn't have to rewrite the logging logic for every single provider.

interface PaymentProcessor {
  processPayment(amount: number): void;
  
  // Bug: Interfaces cannot contain implementation logic!
  logTransaction(amount: number): void {
    console.log(`Transaction of ${amount} recorded at ${new Date().toISOString()}`);
  }
}

class StripeProcessor implements PaymentProcessor {
  processPayment(amount: number) {
    console.log(`Charging $${amount} via Stripe...`);
    this.logTransaction(amount);
  }

  logTransaction(amount: number) {
    // They ended up having to redefine it here anyway...
    console.log(`Transaction of ${amount} recorded at ${new Date().toISOString()}`);
  }
}

The "Interface Implementation" Wall

The first thing you'll notice is that TypeScript will throw a fit. Interfaces are contracts; they describe the shape of an object, not the behavior. You cannot put a function body inside an interface. In the example above, the developer tried to write the logic for logTransaction right there in the interface, which is a hard "no" in TypeScript.

To "fix" the error, the developer did what most of us do when we're frustrated: they just copied and pasted the logTransaction code into every single class that implements the interface. Now we have a maintenance nightmare. If we want to change the date format in the logs, we have to hunt down every single payment class in the codebase.

Bridging the Gap with Abstract Classes

This is exactly where an abstract class comes in. Think of an abstract class as a middle ground. It's a class that can't be instantiated on its own, but it can provide actual code (implementation) that its children can inherit.

Here is how I'd refactor this to stop the copy-pasting while still forcing every processor to implement its own payment logic:

abstract class BasePaymentProcessor {
  // This is a concrete method. Every child gets this for free.
  logTransaction(amount: number): void {
    console.log(`Transaction of ${amount} recorded at ${new Date().toISOString()}`);
  }

  // This is an abstract method. No implementation here.
  // Every child MUST implement this, or TS will complain.
  abstract processPayment(amount: number): void;
}

class StripeProcessor extends BasePaymentProcessor {
  processPayment(amount: number) {
    console.log(`Charging $${amount} via Stripe...`);
    this.logTransaction(amount); // Works perfectly!
  }
}

class PayPalProcessor extends BasePaymentProcessor {
  processPayment(amount: number) {
    console.log(`Redirecting to PayPal for $${amount}...`);
    this.logTransaction(amount); // No duplication!
  }
}

Notice that I changed implements to extends. Because BasePaymentProcessor is a class, we are now using inheritance. The abstract keyword on processPayment tells TypeScript: "I don't know how each provider processes a payment, but I guarantee that any class inheriting from me will figure it out."

Choosing Between the Two

I often get asked: "If abstract classes can do everything interfaces can, why ever use an interface?" It's a fair question. But there's a massive architectural difference: Single Inheritance vs. Multiple Implementation.

  • Abstract Classes: A class can only extend one other class. If your StripeProcessor already extends a ThirdPartyApiBase class, it cannot also extend BasePaymentProcessor. You're locked in.
  • Interfaces: A class can implement as many interfaces as you want. It can be a PaymentProcessor, a Refundable, and a Loggable all at once.

My rule of thumb? Use an interface when you are defining a capability or a contract that could apply to many different types of objects. Use an abstract class when you have a clear "is-a" relationship and you want to share actual code between closely related classes.




📋 Practical Task

Build a Multi-Channel Notification System

You are building a system that sends notifications via different channels (Email, SMS, and Push). All notifications need to log the delivery attempt to a central database, but the actual delivery mechanism is different for each channel.

Your Requirements:

  • Create an abstract class called NotificationChannel.
  • Add a concrete method logAttempt(message: string) to the abstract class that prints: "Logging attempt to send: [message]".
  • Add an abstract method send(message: string) that must be implemented by children.
  • Create three classes—EmailChannel, SmsChannel, and PushChannel—that extend NotificationChannel.
  • Each child class should implement send()` by printing a message specific to that channel (e.g., "Sending Email: ...") and then calling the logAttempt()` method.
  • To test your code, create an array of NotificationChannel types containing one of each instance, and loop through them calling send("Hello World!").
Rating
0 0

There are no comments for now.

to be the first to leave a comment.