Extended classes

8 minutes read

Inheritance in TypeScript isn't just about sharing every feature and functionality between classes, but also knowing to exclude certain properties or methods from being shared when necessary. We're already familiar with how access modifiers and abstract classes in TypeScript achieve this in different ways. In addition to them, there is also another important keyword we need to know when extending classes: the 'static' keyword. The 'static' keyword is used to declare class-level properties and methods, affecting their access and use. By learning more about extended classes and these concepts, we can gain a clearer understanding of how classes interact and how their behaviors are controlled in scenarios where classes are extended.

Basic syntax of class extension

Before delving into the 'static' keyword, let's quickly review the basic syntax of extended classes in TypeScript. As we know, the practice of inheritance in Object-Oriented Programming (OOP) allows a class to inherit properties and methods from another class. Let's take a look at the code snippet below, which represents a general user in an application:

class User {
    private _email: string;

    constructor(email: string) {
        this._email = email;
    }

    get email(): string {
        return this._email;
    }

    set email(value: string) {
        this._email = value;
    }
}

Here, the code creates a User class that includes a private _email property and a constructor method. When a new User object is instantiated, the constructor method takes an email parameter and assigns it to the _email property. Here, to read (get) or modify (set) the value of the private _email property from outside the class, we use getters and setters. The getter (get email) allows read access to the _email property, and the setter (set email) allows the _email property to be updated, both from outside the class, despite _email being a private property.

Let's assume that we want to add additional functionalities specific to premium users, such as accessing premium content. Therefore, we create a new PremiumUser class that extends the User class using extends keyword:

class PremiumUser extends User {
    private _premiumContent: string[];

    constructor(email: string, premiumContent: string[]) {
        super(email);
        this._premiumContent = premiumContent;
    }

    get premiumContent(): string[] {
        return this._premiumContent;
    }

    set premiumContent(value: string[]) {
        this._premiumContent = value;
    }
}

The constructor in PremiumUser calls the constructor of the User class using the super keyword. It passes the email parameter to the User constructor with super(email), which then sets the _email property. This way, the _email property and its getter and setter methods (email getter and email setter) are inherited from the User class by the PremiumUser class. Consequently, instances of PremiumUser can use the email getter and setter just like instances of User.

Additionally, the PremiumUser class defines its own private property, _premiumContent, along with its getter and setter methods (premiumContent to get and premiumContent to set). The string[] type specified after the get and set keywords indicates that the getter will return an array of strings, and the setter will accept an array of strings as its parameter.

So, here's the layout of the basic syntax; PremiumUser extends User using the extends keyword, making User the superclass and PremiumUser the subclass. The super keyword in PremiumUser's constructor is used to call and initialize the superclass User's constructor.

Overriding properties and methods

When a subclass has a property or method with the same name as one in its superclass, the subclass's property or method overrides the one in the superclass. Similar to the inheritance mechanism in Object-Oriented Programming (OOP), a subclass can also override the properties and methods of its superclass. And, this can be done by giving the subclass's properties or methods the same names as those in the superclass.

Please note that in TypeScript, it's common for both superclasses and subclasses to not have explicit constructors if there's no need for special handling of properties and methods during instantiation. The code series presented here exemplifies this: the subclass doesn't have an explicit constructor, but this doesn't mean it doesn't have one; it simply inherits the default constructor.

Let's consider the code snippet below:

class User {
    protected _userType: string = 'Standard';

    get userType(): string {
        return this._userType;
    }

    set userType(newType: string) {
        this._userType = newType;
    }
}

Here, the User class is defined with a protected property _userType, initially set to 'Standard'. This property is marked as protected, meaning that it can be accessed within the class and by classes derived from it. The User class also includes a getter (get userType()) and a setter (set userType(newType)). The getter method allows reading the value of _userType, while the setter method enables updating its value.

let standardUser = new User();
standardUser.userType = 'Gold';  // Setting userType
console.log(standardUser.userType); // Expected Output: "Gold"

When an instance of the User class, named standardUser, is created, its userType property can be set and retrieved using the defined getter and setter methods. For example, setting standardUser.userType to 'Gold' directly updates the _userType property, and accessing standardUser.userType returns the updated value 'Gold'.

class PremiumUser extends User {
    get userType(): string {
        return `Premium - ${super.userType}`;
    }

    set userType(newType: string) {
        super.userType = `Premium - ${newType}`;
    }
}

Now, to build upon the functionalities of the base User class, assume that we have extended it with a subclass: PremiumUser. The PremiumUser class inherits from User and specifically overrides the userType getter and setter methods. The overridden getter appends 'Premium - ' to the _userType property value, pointing out that the higher status of a PremiumUser. Likewise, the overridden setter method also adds 'Premium - ' at the beginning of any new value set for the userType property. This ensures that the userType consistently reflects the premium level unique to PremiumUser instances.

let premiumUser = new PremiumUser();
premiumUser.userType = 'Gold';  // Setting userType
console.log(premiumUser.userType);  // Expected Output: "Premium - Gold"

And, when we create an instance of PremiumUser named premiumUser, and its userType is set to 'Gold', the overridden setter in PremiumUser modifies this value as 'Premium - Gold'. Subsequently, accessing premiumUser.userType with the overridden getter fetches this modified value, 'Premium - Gold'.

In summary, the overall code provided in this section shows how TypeScript allows subclasses to inherit and extend the properties and methods of a superclass. By overriding methods, subclasses like PremiumUser can provide specialized behavior while keeping the overall structure of their superclass, User.

The 'static' keyword

The 'static' keyword in TypeScript is used to declare properties and methods that belong to the class itself, rather than to any particular instance of the class. This means that static members can be accessed without creating an instance of the class. Let's break down the code example below, which simply represents a library system that keeps track of the total number of books:

class Book {
    static bookCount: number = 0;

    constructor() {
        Book.bookCount++;
    }

    static displayBookCount() {
        console.log(`Total books: ${Book.bookCount}`);
    }
}

Here, the above code creates a Book class with a static property bookCount set to 0. So, when we invoke the static method directly, using the class name, it also displays the current value of bookCount, which is initially 0:

Book.displayBookCount(); // Outputs: Total books: 0

Now, let's consider what happens when instances of the Book class are created. Each time a new Book is created, the constructor increases bookCount by 1, tracking the total number of books created. The class also includes a static method displayBookCount() that prints the total count of books. Both bookCount and displayBookCount() are linked to the Book class overall, rather than to individual book instances. Let's suppose that we create new books:

let book1 = new Book();
let book2 = new Book();

Book.displayBookCount(); // Outputs: Total books: 2

When we create book1 with let book1 = new Book();, the constructor is called, which increments bookCount from 0 to 1.

Then, when we create book2 with let book2 = new Book();, the constructor is called again, which increments bookCount from 1 to 2.

So, when you call Book.displayBookCount();, the displayBookCount() method accesses the current value of bookCount, which is now 2 because two Book objects have been created. The method then logs Total book: 2 to the console. This is also a simple way to keep track of the total number of instances of a class that have been created.

Furthermore, notice that we can access the displayBookCount() method directly by using the class name as Book.displayBookCount(). Again, since it is a static method, this call can be done directly on the Book class, without the need to create and call instances of the class.

Static vs. instance members in extended classes

We know that, when a class is extended, the subclass inherits all the accessible instance properties and methods from the superclass, depending on their access modifiers (like public, private, or protected). However, static properties and methods are not inherited in this way. They remain attached to the original class and must be accessed through that class.

Let's extend our Book class to create an EBook, adding additional features, such as a format property that stores the digital format of the e-book:

class EBook extends Book {
    private format: string;

    constructor(format: string) {
        super();
        this.format = format;
    }

    displayFormat() {
        console.log(`The format of this ebook is: ${this.format}`);
    }

    static displayTotalBooks() {
        // Accessing static method of the Book class
        Book.displayBookCount();
    }
}

Here, the EBook class has a private property format, a constructor, a method displayFormat() that logs the format of the EBook instance, and a static method displayTotalBooks(). The constructor in EBook calls the super() function to invoke the constructor of the superclass, Book. Then, this.format = format assigns the passed format value to the format property of the EBook instance.

The displayTotalBooks() static method in the EBook class calls the displayBookCount() static method of the Book class, using the class name directly as Book.displayBookCount(). This is because static methods are part of the class itself, not instances of the class, and that's why they are not inherited in the same way as instance methods and properties.

Additionally, note that the displayTotalBooks() method in EBook is not calling displayBookCount() from Book because the Book is its superclass. Instead, it's directly invoking the static method displayBookCount() from Book. This shows how static methods are used in TypeScript: they are attached to the constructor of the class, not its prototype, which is why they do not participate in the prototype chain.

let eBook1 = new EBook();
let eBook2 = new EBook();

Book.displayBookCount(); // Outputs: Total books: 4

Lastly, in this case, even though we've created two new EBook instances, the Book's static method still correctly counts the total number of Book instances, including those that are instances of EBook. It's because, it accesses the shared static counter which has been incremented for every Book and EBook instance. Therefore, it gives you the total count of both Book and EBook instances.

Conclusion

Understanding how to extend classes and the role of the 'static' keyword is crucial when working with TypeScript and object-oriented programming. While instance properties and methods can be inherited and used by subclasses, static properties and methods remain attached to the class they were defined in. This understanding allows us to design classes and class hierarchies more effectively, and to leverage the full power of object-oriented principles in our TypeScript programs.

6 learners liked this piece of theory. 1 didn't like it. What about you?
Report a typo