Working With Classes

If you are used to working with object-oriented programming, chances are you are already familiar with classes and you can skip this section. But if you are not or need a little reminder, continue reading below.

What is a class?

A class is a blueprint for creating objects that share the same properties and methods. It defines the structure of an object, including its data fields (attributes) and the functions that operate on those fields (methods).
It is a way of organizing and structuring code, and it provides a way to create objects that have a common structure and behavior. Classes can be used to model real-world entities, such as cars, animals, or people, and they can be used to create abstract concepts, such as a vector or a linked list.

Defining a class

You can create a class using the class keyword, followed by the name of the class. Here is an example of a simple Person class:
class Person {
  // class body
}
The class body is where we define the attributes and methods of the class. For example, we could define a name attribute and a greet() method like this:
class Person {
  name: string;

  greet() {
    console.log(`Hello, my name is ${this.name}`);
  }
}
The name attribute is a string, and the greet() method prints a greeting message to the console. Note that we use the this keyword to refer to the current object’s attributes and methods.

The constructor

The constructor function is a special function that is called when an object is created from a class. It is used to initialize the object’s data fields and run any bootstrapping logic. Here is an example of a Person class with a constructor function:
class Person {
  name: string;

  constructor(name: string) {
    this.name = name;
  }

  greet() {
    console.log(`Hello, my name is ${this.name}`);
  }
}
The constructor function takes a name argument and assigns it to the name attribute of the object. This ensures that each Person object has a name attribute that is set to the correct value.

Creating objects

Once we have defined a class, we can create objects from that class using the new keyword. Here is an example of creating a Person object and calling its greet() method:
let person = new Person("John");
person.greet();  // prints "Hello, my name is John"
In this example, we create a Person object and assign it to the person variable. We pass the "John" string as an argument to the constructor function, which sets the name attribute of the object. Then we call the object’s greet() method, which prints a greeting message to the console.

Inheritance

Inheritance is a fundamental concept in object-oriented programming (OOP) that allows one class to inherit the attributes and methods of another class. In TypeScript, inheritance is implemented using the extends keyword.
The idea behind inheritance is to create a hierarchy of classes, where a subclass can inherit the attributes and methods of its superclass. This allows us to reuse code and avoid duplication. For example, we could define a Vehicle class that has a drive() method, and then create subclasses for specific types of vehicles, such as Car and Truck, which inherit the drive() method from the Vehicle class.
Here is an example of how we could define a Vehicle class and a Car subclass that inherits from it:
class Vehicle {
  drive() {
    console.log("Driving...");
  }
}

class Car extends Vehicle {
  // additional car-specific methods and attributes
}
In this example, the Car class inherits the drive() method from the Vehicle class. This means that we can create a Car object and call its drive() method, and it will print “Driving…” to the console.
Inheritance allows us to create subclasses that are specialized versions of their superclass. For example, the Car class could have additional methods and attributes that are specific to cars, such as a honk() method or a numDoors attribute. These attributes and methods would be in addition to the ones that it inherits from the Vehicle class.
You can use the super keyword to call the constructor of the superclass from within a subclass. This is often used to pass arguments to the superclass’s constructor and initialize the object’s data fields. Here is an example of using the super keyword to call the Vehicle class’s constructor from within the Car class:
class Vehicle {
  numWheels: number;

  constructor(numWheels: number) {
    this.numWheels = numWheels;
  }

  drive() {
    console.log("Driving...");
  }
}

class Car extends Vehicle {
  numDoors: number;

  constructor(numDoors: number) {
    super(4);  // call the Vehicle class's constructor
    this.numDoors = numDoors;
  }
  
  // additional car-specific methods and attributes
}
In this example, the Car class’s constructor calls the Vehicle class’s constructor using the super keyword. This passes the 4 argument to the Vehicle class’s constructor, which sets the numWheels attribute of the object to 4. The Car class’s constructor then sets the numDoors attribute of the object.

Encapsulation

Encapsulation refers to the mechanism of restricting access to the internal properties and methods of a class from outside it. To achieve that you will need to use accessibility modifiers. In TypeScript, the accessibility modifiers are public, private, and protected. These keywords determine whether a class member can be accessed from outside the class or from within derived classes (subclasses).

public

Class members that are marked as public can be accessed from anywhere, both inside and outside the class where they are defined. Class members that do not have an explicit accessibility modifier are considered public by default.
Here is an example of a Person class with a public attribute:
class Person {
  public name: string;
  
  constructor(name: string) {
    this.name = name;
  }
}
In this example, the name attribute is public, so it can be accessed from anywhere. We can create a Person object and access its name attribute like this:
let person = new Person("John");
console.log(person.name);  // prints "John"
It is a good practice to make it public only what should actually be acessible externally. For internal behaviour of your objects you should use one of the next two accessibility modifiers we are going to see here.

private

The private modifier restricts the accessibility of a class member to within the class in which it is defined. This means that private properties or methods cannot be accessed from outside the class or from derived classes.
Here’s an example:
class Person {
  private name: string;
  
  constructor(name: string) {
    this.name = name;
  }
  
  public sayHello() {
    console.log(`Hello, my name is ${this.name}.`);
  }
}

const john = new Person('John');
john.sayHello(); // Output: "Hello, my name is John."
console.log(john.name); // Compilation error: Property 'name' is private and only accessible within class 'Person'.
In this example, the Person class has a private name property which can only be accessed within the Person class. The constructor of the Person class initializes the name property with the value passed to it.
The sayHello method is declared as a public method, which means it can be accessed from outside the class. The sayHello method uses the name property to log a message to the console. Remember that methods and properties are public by default, so the public keyword can be ommited.
When we create a new Person instance and call the sayHello method, it logs the message to the console with the value of the name property. However, if we try to access the name property directly from outside the class (as shown in the console.log statement), we will get a compilation error, because the name property is private and can only be accessed within the Person class.

protected

The protected modifier is similar to private, in that it restricts the accessibility of a class member. However, protected members can be accessed within the class in which they are defined and within any classes that inherit from that class. This means that protected members can be accessed by the class itself and by any derived classes, but not by code outside of the class hierarchy.
Here’s an example:
class Person {
  protected name: string;
  
  constructor(name: string) {
    this.name = name;
  }
  
  protected sayName() {
    console.log(`My name is ${this.name}.`);
  }
}

class Employee extends Person {
  private id: number;
  
  constructor(name: string, id: number) {
    super(name);
    this.id = id;
  }
  
  public introduce() {
    this.sayName();
    console.log(`I am an employee with ID ${this.id}.`);
  }
}

const john = new Employee('John', 123);
john.introduce(); // Output: "My name is John." followed by "I am an employee with ID 123."
console.log(john.name); // Compilation error: Property 'name' is protected and only accessible within class 'Person' and its subclasses.
john.sayName(); // Compilation error: Property 'sayName' is protected and only accessible within class 'Person' and its subclasses.
In this example, we have a Person class with a protected name property and a protected sayName method. The Employee class extends Person and adds a private id property and a public introduce method that calls the sayName method from the Person class.
We can create an Employee instance and call its introduce method, which calls the sayName method from the Person class. These methods are accessible from the Employee class because it inherits from Person.
However, if we try to access the name property or call the sayName method directly from the Employee instance, we will get a compilation error, because these members are protected and can only be accessed within the Person class and its subclasses.

Abstract classes and methods

An abstract class is a class that cannot be instantiated directly, but can only be used as a base class for other classes. An abstract class can contain both abstract and non-abstract methods and properties.
An abstract method is a method that is declared but does not provide an implementation. Abstract methods are intended to be implemented by the derived classes, which inherit from the abstract class. Abstract methods are declared using the abstract keyword, and must be implemented in the derived class using the override keyword. The override keyword might not be neccesary, depending on your typescript configuration.
Abstract classes are often used to define a common interface or behavior that can be shared by multiple derived classes. By defining an abstract class, developers can ensure that the derived classes adhere to a common contract, while still allowing the derived classes to implement their own specific behavior.
Here is an example of an abstract class in TypeScript:
abstract class Person {
  protected name: string;
  protected age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }

  abstract sayHello(): void;

  walk(distanceInMeters: number = 0) {
    console.log(`${this.name} walked ${distanceInMeters}m.`);
  }
}

class Employee extends Person {
  private id: number;

  constructor(name: string, age: number, id: number) {
    super(name, age);
    this.id = id;
  }

  override sayHello() {
    console.log(`Hello, my name is ${this.name} and my employee ID is ${this.id}.`);
  }
}
In this example, Person is an abstract class that defines a name property, an age property, a walk method, and an abstract sayHello method. Employee is a concrete class that extends Person and adds a new id property. Employee overrides the sayHello method inherited from Person with its own implementation.
Here’s how you could use the Employee class:
const employee = new Employee("John Doe", 30, 12345);
employee.sayHello(); // Output: "Hello, my name is John Doe and my employee ID is 12345."
employee.walk(100); // Output: "John Doe walked 100m."
This is not all you can do with classes, but what was explained so far should be enough to follow what is to come.