TypeScript
Completed
-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
40: Abstract Classes and Interfaces
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
StripeProcessoralready extends aThirdPartyApiBaseclass, it cannot also extendBasePaymentProcessor. You're locked in. - Interfaces: A class can implement as many interfaces as you want. It can be a
PaymentProcessor, aRefundable, and aLoggableall 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, andPushChannel—that extendNotificationChannel. - Each child class should implement
send()` by printing a message specific to that channel (e.g., "Sending Email: ...") and then calling thelogAttempt()` method. - To test your code, create an array of
NotificationChanneltypes containing one of each instance, and loop through them callingsend("Hello World!").
There are no comments for now.