In TypeScript, abstract classes play an effective role in shaping the structure of our code. Imagine them as blueprints for other classes, providing a clear plan for how those classes should be designed. Abstract classes allow us to create a set of rules that other classes must follow, ensuring a consistent and organized approach to our code.
In this topic, we will delve into the concept of abstract classes. We will explore what they are, how they work, and why they are valuable in building robust and maintainable applications. By understanding abstract classes, you'll gain a tool for designing flexible and cohesive TypeScript programs.
What are abstract classes?
Although an abstract class is still a class, the main difference between it and a regular class is that you cannot create instances of it. In other words, it is a superstructure over the group of its derived classes.
An abstract class helps to better visualize how its descendants will look. In essence, it is an abstraction that is intended only for creating descendants.
Declaring and basic usage
In TypeScript, declaring an abstract class differs from declaring a regular class only by adding the abstract keyword before the class keyword.
abstract class AbstractClass {}
Abstract classes — just like regular classes — can extend other regular classes and implement interfaces:
interface IInterface {}
class RegularClass {}
abstract class SuperAbstractClass
extends RegularClass
implements IInterface {}
Also, they can extend other abstract classes:
abstract class SubAbstract class extends SuperAbstractClass {}
These can all be used to create a custom and flexible application's architecture and the communication of its parts.
Abstract methods and properties
Abstract classes can contain both abstract and concrete (standard) methods. Properties and methods marked as abstract cannot contain an implementation, but must be implemented in derived classes. Abstract members must exist within an abstract class that cannot be directly instantiated and may optionally include access modifiers.
abstract class Beverage {
name: string;
price: number;
constructor(name: string, price: number) {
this.name = name;
this.price = price;
}
abstract getDescription(): string; // No implementation
getPrice(): string { // Concrete method must have implementation
return `This drink costs $${this.price}.`
}
}
class Coffee extends Beverage { // Derived class
strength: string;
constructor(name: string, price: number, strength: string){
super(name, price);
this.strength = strength;
}
getDescription(): string { // Implementation of inherited abstract member
return `${this.name} (${this.strength} strength)`;
}
}
const myCoffee = new Coffee('Espresso', 5, 'medium');
console.log(myCoffee.getDescription()) // Output: Espresso (medium strength)
In this code snippet, the abstract class Beverage has the abstract method getDescription — which must be implemented in every child class — and the concrete method getPrice, which applies to all of the abstract class's descendants. If there is one more inherited class of another drink, the getPrice method will log the cost of the drink to the console. Note that although you cannot instantiate an object of an abstract class, it can still have a constructor for initializing objects of subclasses. Without constructors, the assignment of values is made using dot notation:
const myCoffee = new Coffee();
myCoffee.name = 'Espresso';
myCoffee.price = 5;
myCoffee.strength = 'medium';What to remember
Abstract members are like placeholders. They are declared in the abstract class but don't have any actual code. Subclasses must provide implementation for these properties and methods.
Concrete members are the ready-made tools provided by the abstract class. They have actual code that subclasses can use directly. These help in sharing common behavior among different classes.
Abstract classes vs. interfaces
"Interface or abstract class?" is a frequent question, the answer to which is not always obvious. In fact, they are completely different constructs, both in terms of implementation and ideology.
Interfaces are like rules for how different parts of a program should talk to each other. They don't contain the actual instructions for how something works; they just say what a part of the program should be able to do. Interfaces are great for implementing low coupling. A class, implementing an interface, can implement multiple ones.
Abstract classes should implement interfaces to the same extent and for the same purposes as regular classes. They should certainly be used when you want to create a common base type for a group of related classes, as they allow you to define and reuse shared functionality. A derived class can extend only one abstract class.
If you are struggling to tell the difference between an interface and an abstract class, ask yourself: "Are there related classes that have common logic?" Then, consider an abstract class that describes the common logic for all of these classes. The task of interfaces is to describe the interaction between program parts without implementation.
For example, here we have the abstract class Animal, implementing the IAnimal interface. The abstract class Animal serves as a superstructure for the animal classes Lion and Donkey. As they are voicing differently, they should have distinct implementations of the voice method. Interface, on the other hand, acts as a bridge between these classes and other parts of the application.
interface IAnimal {
isPresent: boolean;
voice(): void;
}
abstract class Animal implements IAnimal {
isPresent: boolean;
abstract voice(): void;
}
class Lion extends Animal {
voice(): void {
console.log('ROARRR...')
}
}
class Donkey extends Animal {
voice(): void {
console.log('Hee-haaw!')
}
}
To sum up, interfaces are intended for defining contracts and achieving loose coupling, especially for unrelated classes. Abstract classes are a perfect fit when you want to provide shared implementation and create a common base type for related classes. The choice depends on the specific scenario and the level of abstraction and reusability required in your application.
Conclusion
Through this journey, we've learned that abstract classes act as blueprints, defining both the essential structure (through abstract members) and the shared functionality (via concrete members) for a group of related classes. Their ability to balance abstraction and implementation empowers developers to create robust class hierarchies, ensuring consistent interfaces and promoting code reusability. Now you're equipped to create efficient and well-organized programs, where abstract classes serve as the building blocks of your code.