ref –
- https://www.tutorialsteacher.com/typescript/abstract-class
- https://stackoverflow.com/questions/260666/can-an-abstract-class-have-a-constructor?rq=1
- https://www.typescripttutorial.net/typescript-tutorial/typescript-abstract-classes/
- https://khalilstemmler.com/blogs/typescript/abstract-class/
- https://khalilstemmler.com/blogs/typescript/abstract-class/
TypeScript – Abstract Class
Abstraction removes the need to implement all the low-level details upfront; instead, we can focus on the high-level and figure out the specifics later.
Abstract classes are mainly for inheritance where other children classes derive from parent classes.
An abstract class is typically used to:
- define common behaviors for derived classes to extend
- cannot be instantiated directly (although regularly parent class can be instantiated)
- An abstract method does not contain implementation. It only defines the signature of the method without including the method body, which the child class MUST define
We deal primarily with two types of classes in object-oriented programming languages: concretions and abstractions.
Concrete classes are real, and we can construct objects from them to use at runtime using the new keyword. They’re what most of us end up using when we first start learning object-oriented programming.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// Concrete class class User { private name: string; public getName (): string { return this.name; } constructor (name: string) { this.name = name; } } const user = new User('Khalil'); // Creating an instance // of a concrete class |
On the other hand, abstractions are blueprints or contracts that specify the properties and methods that a concretion should have. We can use them to contractualize the valid structure of an object or a class.
1 2 3 4 5 6 7 8 |
// Interface (abstraction) interface Box { length: number; width: number; } const boxOne: Box = { length: 1, width: 2 }; // ✅ Valid! Has all props const boxTwo: Box = { length: 1 }; // ❌ Not valid, missing prop |
another example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// Interface (abstraction) interface Box { length: number; width: number; } // Concrete class implementing Box abstraction class MobileBox implements Box { // ✅ Valid! Implements all necessary props public length: number; public width: number; constructor (length: number, width: number) { this.length = length; this.width = width; } } let boxThree = new MobileBox(1, 2); |
In the previous examples, we used the interface keyword to create abstractions. However, when we speak of abstractions in object-oriented programming, we’re generally referring to one of two tools:
- interfaces (i.e.: the interface keyword) or
- abstract classes (i.e.: the abstract keyword in front of a class)
We want to focus on defining a contract containing all the necessary things that a subtype needs to provide if they want it to work with the base type — and leave it to developers to implement them?
This satisifes:
Liskov Substitution Principle — since we define valid subtypes, each implementation should work and be interchangeable as long as it implements the contract.
Dependency Inversion — we do not directly depend on the concretions; instead, we rely on the abstraction that the concretions depend on; this keeps our core code unit testable.
Defining common properties
Within this Book abstraction, we can then decide on the contract for a Book. Let us say that all Book subclasses must have author and title properties. We can define them as instance variables and accept them as input using the abstract class constructor.
1 2 3 4 5 6 7 8 9 |
abstract class Book { private author: string; private title: string; constructor (author: string, title: string) { this.author = author; this.title = title; } } |
Defining common logic
We can then can place some of the common logic within the Book abstract class using regular methods.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
abstract class Book { private author: string; private title: string; constructor (author: string, title: string) { this.author = author; this.title = title; } // Common methods public getBookTitle (): string { return this.title; } public getBookAuthor (): string { return this.title; } } |
Remember that an abstract class is an abstraction comparable to an interface. We can’t instantiate it directly. Trying anyway for demonstration purposes, we’ll notice that we get an error that looks like this:
1 2 3 4 |
let book = new Book ( // ❌ error TS2511: Cannot create an 'Robert Greene', // instance of an abstract class. 'The Laws of Human Nature' ); |
We’ll introduce one of our specific types of Book concretions — the PDF class. We extend/inherit the abstract Book class as a new subclass to hook it up.
1 2 3 4 5 6 7 8 9 |
class PDF extends Book { // Extends the abstraction private belongsToEmail: string; constructor (author: string, title: string, belongsToEmail: string) { super(author, title); // Must call super on subclass this.belongsToEmail = belongsToEmail; } } |
Since PDF is a concrete class, we can instantiate it. The PDF object has all of the properties and methods of the Book abstraction, so we can call getBookTitle and getBookAuthor as if it were originally declared on the PDF class.
1 2 3 4 5 6 7 8 |
let book: PDF = new PDF( 'Robert Greene', 'The Laws of Human Nature', 'khalil@khalilstemmler.com' ); book.getBookTitle(); // "The Laws of Human Nature" book.getBookAuthor(); // "Robert Greene" |
Defining mandatory methods
Abstract classes have one last important feature: the notion of the abstract method. Abstract methods are methods that we must define on any implementing subclass.
In the abstract class, we define them like so:
1 2 3 4 5 6 7 8 9 10 11 |
abstract class Book { private author: string; private title: string; constructor (author: string, title: string) { this.author = author; this.title = title; } abstract getBookType (): string; // No implementation } |
Notice that there is no implementation for the abstract method? That’s because we implement it on the subclass.
Demonstrating with the PDF and an additional EPUB subclass, we add the required abstract method to both of them.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
... class PDF extends Book { ... getBookType (): string { // Must implement this return 'PDF'; } } class EPUB extends Book { constructor (author: string, title: string) { super(author, title); } getBookType (): string { // Must implement this return 'EPUB'; } } |
Failing to implement the required abstract methods will fail to make the class complete and concrete — this means we’ll run into errors when trying to compile our code or create instances.
Use cases (when to use abstract classes)
Now that we know the mechanics behind how abstract classes work, let’s talk about real-life use cases for it — scenarios you are likely to encounter.
There are two primary use cases for needing to use abstract classes:
- Sharing common behavior
- Template method pattern (framework hook methods)
Sharing Common Behavior
Let’s say that we definitely need to fetch data from:
a Users API located at example.com/users
a Reviews API located at example.com/reviews
What’s the common logic?
Setting up an HTTP library (like Axios)
Setting up interceptors to set up common refetching logic (say, like when an access token expires)
Performing HTTP methods
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
export abstract class BaseAPI { protected baseUrl: string; private axiosInstance: AxiosInstance | any = null; constructor (baseUrl: string) { this.baseUrl = baseUrl this.axiosInstance = axios.create({}) this.enableInterceptors(); } protected get (url: string, params?: any, headers?: any): Promise<any> { return this.getAxiosInstance({ method: 'GET', url: `${this.baseUrl}${url}`, params: params ? params : null, headers: headers ? headers : null }) } protected post (url: string, params?: any, headers?: any): Promise<any> { return this.getAxiosInstance({ method: 'POST', url: `${this.baseUrl}${url}`, params: params ? params : null, headers: headers ? headers : null }) } } |
Here, we’ve placed the low-level behavior within a BaseAPI abstraction. Now we can define the high-level behavior — the rich stuff — in the subclasses.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
export class UsersAPI extends BaseAPI { constructor () { super('http://example.com/users'); } // High-level functionality async getAllUsers (): Promise<User[]> { let response = await this.get('/'); return response.data.users as User[]; } ... } |
So now we can get all sort of data whether it us Users, Reviewers…etc.
Template Method Pattern ( framework hooking )
So then, a simplistic version of the abstract class for a component could look like the following:
1 2 3 4 5 6 |
abstract class Component { private props: any; private state: any; abstract render (): void; // Mandatory } |
And then to use it, we’d need to extend the Component abstraction and implement the render method.
1 2 3 4 5 |
class Box extends Component { render () { // Must implement this method } } |
We need to implement the render method because it is a critical part of the work involved with deciding what gets created on screen.
An abstract class is a suitable tool for this problem is because there is an algorithm running behind the scenes — an algorithm in which the render step is but a single step amongst many.
We can see that there are three distinct phases to React: mounting, updating, and unmounting. React’s abstract class further gives us (the client developer) the ability to customize our components by connecting the ability to hook into various lifecycle events. For example, you can perform some behavior as soon as your component mounts (componentDidMount), when it updates (componentDidUpdate), and when it’s about to unmount (componentWillUnmount).
so the algorithm may look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
abstract class Component { private props: any; private state: any; abstract render (): void; // Algorithm for mounting constructor () { this.render(); this.componentDidMount(); } // The general algorithm for updating. Called when new props occur, // when setState is called or when forceUpdate is called onUpdate () { if (this.componentShouldUpdate()) { this.render(); this.componentDidUpdate(); } } // Algorithm for unmounting onUnmount () { this.componentWillUnmount(); // Complete unmounting } public componentDidMount (): void { // No implementation lifecycle method, allow the client to override } public componentDidUpdate (): void { // No implementation lifecycle method, allow the client to override } public componentWillUnmount (): void { // No implementation lifecycle method, allow the client to override } private componentShouldUpdate (): boolean { // Determines if the component should update ... } } |
And now, a customized component subclass can plug in behavior within those key lifecycle hook methods. This is the concept of Inversion of Control.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class Box extends Component { constructor () { super(); } componentDidMount () { // (Optional) Perform custom logic } componentDidUpdate () { // (Optional) Perform custom logic } componentWillUnmount () { // (Optional) Perform custom logic } render () { // Must implement this method } } |
So as a framework designer, the Template Method design pattern is attractive when:
- You need the client to implement a step of the algorithm → so you make that step an abstract method
You want to provide the ability for clients to customize behavior at various steps of the algorithm → so you expose optional lifecycle methods to the client
Supplementals
The following abstract class declares one abstract method find and also includes a normal method display.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
abstract class Person { name: string; constructor(name: string) { this.name = name; } display(): void{ console.log(this.name); } abstract find(string): Person; } class Employee extends Person { empCode: number; constructor(name: string, code: number) { super(name); // must call super() this.empCode = code; } find(name:string): Person { // execute AJAX request to find an employee from a db return new Employee(name, 1); } } let emp: Person = new Employee("James", 100); emp.display(); //James let emp2: Person = emp.find('Steve'); |
In the above example, Person is an abstract class which includes one property and two methods, one of which is declared as abstract.
The find() method is an abstract method and so must be defined in the derived class. In other words, the Employee class derives from the Person class and so it must define the find() method as abstract.
The Employee class must implement all the abstract methods of the Person class, otherwise the compiler will show an error.
Note:
The class which implements an abstract class must call super() in the constructor.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
abstract class Person { abstract name: string; // The abstract class can also include an abstract property // does not have abstract keyword. Derived classes do not have to implement this. // Instead they can call and use this. display(): void { console.log(this.name); } } class Employee extends Person { name: string; // this property must be declared as indicated in the abstract class empCode: number; constructor(name: string, code: number) { super(); // must call super() this.empCode = code; this.name = name; } } let emp: Person = new Employee("James", 100); emp.display(); //James |