In this article, we will see how to group our functions in reusable components, such as classes or modules. This article will cover the following topics:
(For more resources related to this topic, see here.)
In the early days of software development, developers used to write code with procedural programming languages. In procedural programming languages, the programs follow a top-to-bottom approach and the logic is wrapped with functions.
New styles of computer programming, such as modular programming or structured programming, emerged when developers realized that procedural computer programs could not provide them with the desired level of abstraction, maintainability, and reusability.
The development community created a series of recommended practices and design patterns to improve the level of abstraction and reusability of procedural programming languages, but some of these guidelines required a certain level of expertise. In order to facilitate adherence to these guidelines, a new style of computer programming known as object-oriented programming (OOP) was created.
Developers quickly noticed some common OOP mistakes and came up with five rules that every OOP developer should follow to create a system that is easy to maintain and extend over time. These five rules are known as the SOLID principles. SOLID is an acronym introduced by Michael Feathers, which stands for the following principles:
In this article, we will see how to write TypeScript code that adheres to these principles so that our applications are easy to maintain and extend over time.
In this section, we will look at some details and OOP concepts through examples. Let's start by declaring a simple class:
class Person {
public name : string;
public surname : string;
public email : string;
constructor(name : string, surname : string, email : string){
this.email = email;
this.name = name;
this.surname = surname;
}
greet() {
alert("Hi!");
}
}
var me : Person = new Person("Remo", "Jansen",
"remo.jansen@wolksoftware.com");
We use classes to represent the type of an object or entity. A class is composed of a name, attributes, and methods. The class in the preceding example is named Person and contains three attributes or properties (name, surname, and email) and two methods (constructor and greet). Class attributes are used to describe the object's characteristics, while class methods are used to describe its behavior.
A constructor is a special method used by the new keyword to create instances (also known as objects) of our class. We have declared a variable named me, which holds an instance of the Person class. The new keyword uses the Person class's constructor to return an object whose type is Person.
A class should adhere to the single responsibility principle (SRP). The Person class in the preceding example represents a person, including all their characteristics (attributes) and behaviors (methods). Now let's add some email as validation logic:
class Person {
public name : string;
public surname : string;
public email : string;
constructor(name : string, surname : string, email : string) {
this.surname = surname;
this.name = name;
if(this.validateEmail(email)) {
this.email = email;
}
else {
throw new Error("Invalid email!");
}
}
validateEmail() {
var re = /S+@S+.S+/;
return re.test(this.email);
}
greet() {
alert("Hi! I'm " + this.name + ". You can reach me at " +
this.email);
}
}
When an object doesn't follow the SRP and it knows too much (has too many properties) or does too much (has too many methods), we say that the object is a God object. The Person class here is a God object because we have added a method named validateEmail that is not really related to the Person class's behavior.
Deciding which attributes and methods should or should not be part of a class is a relatively subjective decision. If we spend some time analyzing our options, we should be able to find a way to improve the design of our classes.
We can refactor the Person class by declaring an Email class, responsible for e-mail validation, and use it as an attribute in the Person class:
class Email {
public email : string;
constructor(email : string){
if(this.validateEmail(email)) {
this.email = email;
}
else {
throw new Error("Invalid email!");
}
}
validateEmail(email : string) {
var re = /S+@S+.S+/;
return re.test(email);
}
}
Now that we have an Email class, we can remove the responsibility of validating the emails from the Person class and update its email attribute to use the type Email instead of string:
class Person {
public name : string;
public surname : string;
public email : Email;
constructor(name : string, surname : string, email : Email){
this.email = email;
this.name = name;
this.surname = surname;
}
greet() {
alert("Hi!");
}
}
Making sure that a class has a single responsibility makes it easier to see what it does and how we can extend/improve it. We can further improve our Person and Email classes by increasing the level of abstraction of our classes. For example, when we use the Email class, we don't really need to be aware of the existence of the validateEmail method; so this method could be invisible from outside the Email class. As a result, the Email class would be much simpler to understand.
When we increase the level of abstraction of an object, we can say that we are encapsulating the object's data and behavior. Encapsulation is also known as information hiding. For example, the Email class allows us to use emails without having to worry about e-mail validation because the class will deal with it for us. We can make this clearer by using access modifiers (public or private) to flag as private all the class attributes and methods that we want to abstract from the use of the Email class:
class Email {
private email : string;
constructor(email : string){
if(this.validateEmail(email)) {
this.email = email;
}
else {
throw new Error("Invalid email!");
}
}
private validateEmail(email : string) {
var re = /S+@S+.S+/;
return re.test(email);
}
get():string {
return this.email;
}
}
We can then simply use the Email class without needing to explicitly perform any kind of validation:
var email = new Email("remo.jansen@wolksoftware.com");
The feature that we will miss the most when developing large-scale web applications with JavaScript is probably interfaces. We have seen that following the SOLID principles can help us to improve the quality of our code, and writing good code is a must when working on a large project. The problem is that if we attempt to follow the SOLID principles with JavaScript, we will soon realize that without interfaces, we will never be able to write SOLID OOP code. Fortunately for us, TypeScript features interfaces.
Traditionally, in OOP, we say that a class can extend another class and implement one or more interfaces. An interface can implement one or more interfaces and cannot extend another class or interface. Wikipedia's definition of interfaces in OOP is as follows:
In object-oriented languages, the term interface is often used to define an abstract type that contains no data or code, but defines behaviors as method signatures.
Implementing an interface can be understood as signing a contract. The interface is a contract, and when we sign it (implement it), we must follow its rules. The interface rules are the signatures of the methods and properties, and we must implement them.
We will see many examples of interfaces later in this article.
In TypeScript, interfaces don't strictly follow this definition. The two main differences are that in TypeScript:
In OOP, classes can have some kind of relationship with each other. Now, we will take a look at the three different types of relationships between classes.
We call association those relationships whose objects have an independent lifecycle and where there is no ownership between the objects. Let's take an example of a teacher and student. Multiple students can associate with a single teacher, and a single student can associate with multiple teachers, but both have their own lifecycles (both can be create and delete independently); so when a teacher leaves the school, we don't need to delete any students, and when a student leaves the school, we don't need to delete any teachers.
We call aggregation those relationships whose objects have an independent lifecycle, but there is ownership, and child objects cannot belong to another parent object. Let's take an example of a cell phone and a cell phone battery. A single battery can belong to a phone, but if the phone stops working, and we delete it from our database, the phone battery will not be deleted because it may still be functional. So in aggregation, while there is ownership, objects have their own lifecycle.
We use the term composition to refer to relationships whose objects don't have an independent lifecycle, and if the parent object is deleted, all child objects will also be deleted.
Let's take an example of the relationship between questions and answers. Single questions can have multiple answers, and answers cannot belong to multiple questions. If we delete questions, answers will automatically be deleted.
Objects with a dependent life cycle (answers, in the example) are known as weak entities.
Sometimes, it can be a complicated process to decide if we should use association, aggregation, or composition. This difficulty is caused in part because aggregation and composition are subsets of association, meaning they are specific cases of association.
One of the most fundamental object-oriented programming features is its capability to extend existing classes. This feature is known as inheritance and allows us to create a new class (child class) that inherits all the properties and methods from an existing class (parent class). Child classes can include additional properties and methods not available in the parent class. Let's return to our previously declared Person class. We will use the Person class as the parent class of a child class named Teacher:
class Person {
public name : string;
public surname : string;
public email : Email;
constructor(name : string, surname : string, email : Email){
this.name = name;
this.surname = surname;
this.email = email;
}
greet() {
alert("Hi!");
}
}
This example is included in the companion source code.
Once we have a parent class in place, we can extend it by using the reserved keyword extends. In the following example, we declare a class called Teacher, which extends the previously defined Person class. This means that Teacher will inherit all the attributes and methods from its parent class:
class Teacher extends Person {
class Teacher extends Person {
teach() {
alert("Welcome to class!");
}
}
Note that we have also added a new method named teach to the class Teacher. If we create instances of the Person and Teacher classes, we will be able to see that both instances share the same attributes and methods with the exception of the teach method, which is only available for the instance of the Teacher class:
var teacher = new Teacher("remo", "jansen", new
Email("remo.jansen@wolksoftware.com"));
var me = new Person("remo", "jansen", new
Email("remo.jansen@wolksoftware.com"));
me.greet();
teacher.greet();
me.teach(); // Error : Property 'teach' does not exist on type
'Person'
teacher.teach();
Sometimes, we will need a child class to provide a specific implementation of a method that is already provided by its parent class. We can use the reserved keyword super for this purpose. Imagine that we want to add a new attribute to list the teacher's subjects, and we want to be able to initialize this attribute through the teacher constructor. We will use the super keyword to explicitly reference the parent class constructor inside the child class constructor. We can also use the super keyword when we want to extend an existing method, such as greet. This OOP language feature that allows a subclass or child class to provide a specific implementation of a method that is already provided by its parent classes is known as method overriding.
class Teacher extends Person {
public subjects : string[];
constructor(name : string, surname : string, email : Email, subjects : string[]){
super(name, surname, email);
this.subjects = subjects;
}
greet() {
super.greet();
alert("I teach " + this.subjects);
}
teach() {
alert("Welcome to Maths class!");
}
}
var teacher = new Teacher("remo", "jansen", new
Email("remo.jansen@wolksoftware.com"), ["math", "physics"]);
We can declare a new class that inherits from a class that is already inheriting from another. In the following code snippet, we declare a class called SchoolPrincipal that extends the Teacher class, which extends the Person class:
class SchoolPrincipal extends Teacher {
manageTeachers() {
alert("We need to help students to get better results!");
}
}
If we create an instance of the SchoolPrincipal class, we will be able to access all the properties and methods from its parent classes (SchoolPrincipal, Teacher, and Person):
var principal = new SchoolPrincipal("remo", "jansen", new
Email("remo.jansen@wolksoftware.com"), ["math", "physics"]);
principal.greet();
principal.teach();
principal.manageTeachers();
It is not recommended to have too many levels in the inheritance tree. A class situated too deeply in the inheritance tree will be relatively complex to develop, test, and maintain. Unfortunately, we don't have a specific rule that we can follow when we are unsure whether we should increase the depth of the inheritance tree (DIT).
We should use inheritance in such a way that it helps us to reduce the complexity of our application and not the opposite. We should try to keep the DIT between 0 and 4 because a value greater than 4 would compromise encapsulation and increase complexity.
In this article, we saw how to work with classes, and interfaces in depth. We were able to reduce the complexity of our application by using techniques such as encapsulation and inheritance.
To learn more about TypeScript, the following books published by Packt Publishing (https://www.packtpub.com/) are recommended:
Further resources on this subject: