Typescript Tutorial Series - 04. Classes, Interfaces and OOP

Typescript Tutorial Series - 04. Classes, Interfaces and OOP

Hi there👋

My name is Milad and welcome to the fourth part of the typescript tutorial series. In this part, we are going to talk about object-oriented programming, classes, constructors, properties & methods, access control keywords, getters & setters, static members, index signatures, inheritance, polymorphism, abstract classes and interfaces. So stay with me through this amazing and fun journey.


What OOP Is

Object-oriented programming is one of the programming paradigms(styles) out there such as functional, procedural, event-driven, etc. The main idea behind OOP is to break down programs into smaller, reusable pieces called objects. Each object is like a little machine that has data(called properties or attributes) and functionality(called methods).

For example, suppose we have a Dog object that has properties like name, breed, color, ... and methods like bark(), run(), eat(), etc. Luckily for us, javascript and typescript languages both support object-oriented paradigm.

NOTE that each programming paradigm like OOP, Functional or Procedural has unique strengths and tradeoffs. For example, OOP code promotes reusability but can also be complex, functional code avoids side effects but requires different thinking and procedural code is simple but it is not scalable and it can not structure big codebases.

So in a nutshell, every tool that we use like programming languages or paradigms, ... has pros and cons and there is no best programming language or paradigm.


Creating Classes

OOP is all about classes and objects. Classes are blueprints for creating objects. They encapsulate data(properties or attributes) and behavior (methods). Let's create a class and define its properties as an example:

class Dog {
    name: string;
    breed: string;
    age: number;
    color: string;
}

As you can see there are some compilation errors. Let's take a look by hovering the mouse pointer over one of them:

To solve this issue, we need to define the constructor method. The constructor method is a special function for initializing an object. It should not include return type annotation because it always returns an instance of its class:

class Dog {
    name: string;
    breed: string;
    age: number;
    color: string;

    constructor(name: string, breed: string, age: number, color: string) {
        if (!name || !breed || !age || !color) throw new Error("Constructor parameters are mandatory!");
        this.name = name;
        this.breed = breed;
        this.age = age;
        this.color = color;
    }
}

Classes also can have methods. A method is a function that is defined inside a class. Let's implement some for our class:

class Dog {
    name: string;
    breed: string;
    age: number;
    color: string;

    constructor(name: string, breed: string, age: number, color: string) {
        if (!name || !breed || !age || !color) throw new Error("Constructor parameters are mandatory!");
        this.name = name;
        this.breed = breed;
        this.age = age;
        this.color = color;
    }

    bark(): void {
        console.log("The dog is barking");
    }

    run(): void {
        console.log("The dog is running");
    }

    eat(foodName: string): void | never {
        if (!foodName) throw new Error("foodName parameter is mandatory!");
        console.log(`The dog is eating ${foodName}`);
    }
}

Now let's compile the previous piece of code and take a look at the generated javascript code:

$ tsc
"use strict";
class Dog {
    constructor(name, breed, age, color) {
        if (!name || !breed || !age || !color)
            throw new Error("Constructor parameters are mandatory!");
        this.name = name;
        this.breed = breed;
        this.age = age;
        this.color = color;
    }
    bark() {
        console.log("The dog is barking");
    }
    run() {
        console.log("The dog is running");
    }
    eat(foodName) {
        if (!foodName)
            throw new Error("Food name parameter is mandatory!");
        console.log(`The dog is eating ${foodName}`);
    }
}
//# sourceMappingURL=index.js.map

As you can see, the class definition in the javascript and the typescript is quite different; For example, properties and type annotations are all gone in the generated index.js file.


Creating An Object

We need to use new keyword to create an object. To do so, we should first define a variable or a constant using let or const keywords followed by the object name and an assignment operator followed by new keyword and then the desired class name followed by open and close parenthesis:

const myDog = new Dog();

As soon as we type the previous piece of code, a context menu appears on the screen that is related to the constructor method:

As you might guess, we should fill its arguments with proper value to create an object successfully:

const myDog = new Dog("Julia", "Poodle", 2, "White");

With this, we have created our object successfully. Let's access the object properties and display them all at once:

console.log(`Name: ${myDog.name}\nBreed: ${myDog.breed}\nAge: ${myDog.age}\nColor: ${myDog.color}`);

/*
OUTPUT:

Name: Julia
Breed: Poodle
Age: 2
Color: White
*/

Now let's run the methods that are related to myDog object:

myDog.bark();
myDog.run();
myDog.eat("Meat");

Since the eat method gets an argument, we should pass a proper value to it otherwise, we will get an error. Here is the output of methods:

# OUTPUT
# The dog is barking
# The dog is running
# The dog is eating Meat

Let's check the type of our object:

console.log(typeof myDog);

/*
OUTPUT:
object
*/

The previous piece of code will display object which is a general result because typeof keyword typically is used to check the primitive type of a value such as string, number, boolean. It can also be used to check if a value is null or undefined. This is where instanceof comes into play which is typically used to check if a value is an instance of a class or an interface:

console.log(myDog instanceof Dog);

/*
OUTPUT:

true
*/

ReadOnly Properties

Let's consider a scenario that the dog breed cannot be changed and its value is persistent during the program lifetime. To implement this feature we need to use read-only class property which can only be assigned once, typically at declaration or in the constructor method. Once it is assigned, its value cannot be changed. To define a read-only class property, we use readonly keyword before the property name as follows:

class Dog {
    name: string;
    readonly breed: string;
    age: number;
    color: string;

    // All other methods such as constructor, bark, run and eat are the same.
}

Now if we try to change its value as follows:

// run method is defined inside Dog class:

run() {
    console.log("The dog is running");
    this.breed = "German";
}

Or

// After creating an object

myDog.breed = "German";

We will get an error that says: "cannot assign to 'breed' because it is a read-only property".


Optional Properties

Let's assume a dog can have an owner but it is optional. To implement this feature in our class, we need to get help from optional class properties. Optional class properties can omitted when creating a new instance of a class. If an optional class property is omitted, it will be undefined. To define an optional class property we use a question mark(?) after the property name as follows:

class Dog {
    name: string;
    readonly breed: string;
    age: number;
    color: string;
    ownerName?: string;

    constructor(name: string, breed: string, age: number, color: string, ownerName?: string) {
        if (!name || !breed || !age || !color) {
            throw new Error("Constructor parameters are mandatory!");
        }
        this.name = name;
        this.breed = breed;
        this.age = age;
        this.color = color;
        this.ownerName = ownerName;
    }

    // All other methods such as bark, run and eat are the same.
}

Now let's try to display myDog object properties with and without passing ownerName property value:

// WITHOUT PASSING

const myDog = new Dog("Julia", "Poodle", 2, "White");

console.log(`Name: ${myDog.name}\nBreed: ${myDog.breed}\nAge: ${myDog.age}\nColor: ${myDog.color}\nownerName: ${myDog.ownerName}`);

/*
OUTPUT:

Name: Julia
Breed: Poodle
Age: 2
Color: White
ownerName: undefined

*/
// WITH PASSING

const myDog = new Dog("Julia", "Poodle", 2, "White", "Milad");

console.log(`Name: ${myDog.name}\nBreed: ${myDog.breed}\nAge: ${myDog.age}\nColor: ${myDog.color}\nownerName: ${myDog.ownerName}`);

/*
OUTPUT:

Name: Julia
Breed: Poodle
Age: 2
Color: White
ownerName: Milad

*/

As you can see if we don't pass any value to ownerName argument in the constructor, its value will be undefined.


Access Control Keywords

Let's talk about access control modifiers in the typescript. In typescript, there are three access control modifiers including:

  1. Public

  2. Private

  3. Protected

We will talk about protected modifier in the next chapters because to understand its functionality, we need to know more about the typescript features. But now let's learn and compare public and private modifiers.

Public: public properties are accessible from anywhere in our code. This is the default access control modifier for properties of a typescript class.

Private: private properties are only accessible within the class in which they are defined.

Let's see an example to make the above concepts crystal clear. This is the class Dog we have defined so far:

class Dog {
    name: string;
    readonly breed: string;
    age: number;
    color: string;
    ownerName?: string;

    constructor(name: string, breed: string, age: number, color: string, ownerName?: string) {
        if (!name || !breed || !age || !color) {
            throw new Error("Constructor parameters are mandatory!");
        }
        this.name = name;
        this.breed = breed;
        this.age = age;
        this.color = color;
        this.ownerName = ownerName;
    }

    bark(): void {
        console.log("The dog is barking");
    }

    run(): void {
        console.log("The dog is running");
    }

    eat(foodName: string): void | never {
        if (!foodName) throw new Error("foodName parameter is mandatory!");
        console.log(`The dog is eating ${foodName}`);
    }
}

Let's try to display Dog's ownerName on the console:

const myDog = new Dog("julia", "poodle", 2, "white", "Milad");
console.log(myDog.ownerName);

We need to compile it using typescript compiler(tsc) and run it using nodeJS:

$ tsc
$ node dist/index.js

# OUTPUT:
# Milad

As you can see, ownerName property is accessible from outside of its class.

Assume that we want to make the Dog's ownerName private so that it is not accessible from outside of its class. This is where the private modifier comes into play. To use the private modifier, we need to add it before the property name as follows:

class Dog {
    name: string;
    readonly breed: string;
    age: number;
    color: string;
    private ownerName?: string

    // other parts of the class are the same
}

As soon as we type the private modifier, we get a compilation error on the following line of the code:

console.log(myDog.ownerName);

That says: "Property ownerName is private and only accessible within class Dog". With this, we are no longer allowed to access and modify ownerName property as follows:

// These lines of codes do not work anymore
console.log(myDog.ownerName);
myDog.ownerName = "Jack";

Now what if we want to securely allow users to access ownerName property without giving them access to modify its value? What I am saying is that:

console.log(myDog.ownerName);

Somehow will work while:

myDog.ownerName = "Jack";

Won't work.

This is where we should use helper methods; but before we define it, we need to add a leading underscore(_) to the ownerName property which is a naming convention to demonstrate private properties inside the typescript class:

class Dog {
    name: string;
    readonly breed: string;
    age: number;
    color: string;
    private _ownerName?: string;

    constructor(name: string, breed: string, age: number, color: string, _ownerName?: string) {
        if (!name || !breed || !age || !color) {
            throw new Error("Constructor parameters are mandatory!");
        }
        this.name = name;
        this.breed = breed;
        this.age = age;
        this.color = color;
        this._ownerName = _ownerName;
    }

    // All the other parts of the class are the same
}

Now we can define the helper method as follows:

class Dog {
    // All the other parts are the same

    // Helper method:
    getOwnerName(): string | undefined {
        return this._ownerName;
    }
}

To use getOwnerName() method, we need to call it instead of trying to access directly to ownerName property:

console.log(myDog.getownerName());

// OUTPUT:
// Milad

The access control modifiers in the typescript are not only about class properties; we can apply them to the class methods as well. Let's see a simple example to demonstrate how it works. Let's assume we want to make method bark() private so that it's only accessible within its class. To do so, we just need to add the keyword private before method name as follows:

class Dog {
    // All the other parts of the class are the same

    private bark(): void {
        console.log("The dog is barking");
    }
}

Now if we try to call the method bark() outside of its class like this:

myDog.bark();

We will get the same compilation error.


Parameter Properties

Let's talk about another cool feature of the typescript. This is our class Dog with its properties and constructor method:

class Dog {
    name: string;
    readonly breed: string;
    age: number;
    color: string;
    private _ownerName?: string;

    constructor(name: string, breed: string, age: number, color: string, _ownerName?: string) {
        if (!name || !breed || !age || !color) {
            throw new Error("Constructor parameters are mandatory!");
        }
        this.name = name;
        this.breed = breed;
        this.age = age;
        this.color = color;
        this._ownerName = _ownerName;
    }
}

Every time that we want to define a class, we need to write this repetitive and boring pattern(declaring class properties and initializing them inside the constructor method). But there is a better way; this is where the parameter properties come into play. In typescript, parameter properties are a shorthand syntax for declaring a constructor parameter and a class property in one place. This can make our code more concise and easier to read. To declare a parameter property, simply prefix the constructor parameter with an access control modifier or readonly or both. So we can refactor class Dog like below:

class Dog {
  constructor(
    public name: string,
    public readonly breed: string,
    public age: number,
    public color: string,
    private _ownerName?: string,
  ) {
    if (!name || !breed || !age || !color) {
      throw new Error("Constructor parameters are mandatory!");
    }
  }
}

Let's try to compile the index.ts file and take a look at the generated index.js file:

$ tsc

Now open up the index.js file:

"use strict";
class Dog {
    constructor(name, breed, age, color, _ownerName) {
        this.name = name;
        this.breed = breed;
        this.age = age;
        this.color = color;
        this._ownerName = _ownerName;
        if (!name || !breed || !age || !color) {
            throw new Error("Constructor parameters are mandatory!");
        }
    }
    bark() {
        console.log("The dog is barking");
    }
    run() {
        console.log("The dog is running");
    }
    eat(foodName) {
        if (!foodName)
            throw new Error("foodName parameter is mandatory!");
        console.log(`The dog is eating ${foodName}`);
    }
    getOwnerName() {
        return this._ownerName;
    }
}

As you can see, what we have done is only related to the typescript and the generated javascript code is still the same as before.


Getters and Setters

In this section, we are going to discover an important feature of the typescript in the object-oriented paradigm. As you may remember, we have defined a helper function called getOwnerName() to make Dog's ownerName property accessible but not modifiable outside of its class. Now what if we want to access to that property directly as before like this:

console.log(myDog.ownerName);

Instead of calling the helper function as follows:

console.log(myDog.getOwnerName());

This is where getters come into play. Getters are methods that are called when a property is read. To declare a getter, simply use the get keyword followed by the property name:

class Dog {
    // All the other parts of the class are the same

    get ownerName(): string | undefined {
        return this._ownerName;
    }
}

With this, we have direct access to ownerName property outside of the class as before and still we can not modify it. In other words, We simply use object.propertyName syntax while a method is called under the hood.


Now let's assume we accidentally modify myDog's age property like this:

myDog.age = -1;
console.log(myDog.age);

And then try to compile and run it:

$ tsc
$ node dist/index.js

# OUTPUT:
# -1

As you can see, myDog's age is -1 which is not a reasonable value because it does not make sense that a dog is -1 years old. So we need to evaluate the value before assigning it to the property. This is where setters come into play. Setters are methods that are called when a property is modified. To declare a setter, simply use the set keyword followed by the property name:

class Dog {
    // All other parts of the class are the same

    set age(value: number) {
        // The logic is not implemented yet
    }
}

As soon as we define the setter method, we get a compilation error on age property that says: "Duplicate identifier 'age' " which means we have a naming conflict in our code. To solve this issue, we need to rename property age to something like age_ as follows:

class Dog {
  constructor(
    public name: string,
    public readonly breed: string,
    public age_: number,
    public color: string,
    private _ownerName?: string,
  ) {
    if (!name || !breed || !age_ || !color) {
      throw new Error("Constructor parameters are mandatory!");
    }
  }

  set age(value: number) {
    if (value <= 0) throw new Error("Age must be greater than 0!");
    this.age_ = Math.floor(value);
  }
}

With this, we have fixed the previous issue so that we can modify property age value:

myDog.age = 5;

And if we try to pass a value lower than or equal to 0, we will get an error as follows:

Error: Age must be greater than 0!

But there is a hidden bug in our code. Where!! What if we try to access the property age like below?

console.log(myDog.age);

// OUTPUT:
// undefined

Why did we get undefined instead of 5?

The answer is that since we've renamed property age to age_ and have forgotten to define a getter method, our class does not have property age in get functionality.

To solve this issue, we need to define a getter method for property age as well:

class Dog {
    // All the other parts of the class are the same

    get age(): number {
        return this.age_;
    }
}

Now we can run the following piece of code:

console.log(myDog.age);    // output: 5

Index Signatures

As we discussed earlier, in the typescript we can't add propertues to an object dynamically because typescript is strict about the shape of objects. But what if we have a scenario that we should add dynamic properties to an object? This is where index signatures come into play. The index signatures allow us to describe the types of indexes in an object. They are a way to tell typescript that an object can be indexed with a certain type.

An index signature is defined using square brackets which contains the type of keys followed by a colon and the type of the values:

[keyName: typeOfKeys]: typeOfValues;

The type of the keys can be any type but it is most commonly a string ot a number.

Let's assume we are going to implement a service to assign and store a concert hall seats to customers. First we need to define a type called Customer that has two properties called fullName and email as follows:

type Customer = {
    fullName: string;
    email: string;
};

Second we need to implement a class that includes index signature feature as follows:

class SeatAssignment {
    [seatNumber: string]: Customer;
}

With this we can add seats numbers dynamically with their associated customer information just like we did in javascript while we have also type checking and type safety features.

Let's use this feature to make index signatures concept crystall clear to you:

let seats = new SeatAssignment();
seats.AA11 = {fullName: "Milad Sadeghi", email: "msdm@gmail.com"};
seats.BB12 = {fullName: "John Wick", email: "jowi@gmail.com"};

We can also add dynamic properties using square bracket syntax as follows:

seats["CC33"] = {fullName: "Tim Cook", email: "tcook@gmail.com"};

Let's log the value of seats variable at the console:

console.log(seats);

// OUTPUT:
/*
SeatAssignment {
  AA11: { fullName: 'Milad Sadeghi', email: 'msdm@gmail.com' },
  BB12: { fullName: 'John Wick', email: 'jowi@gmail.com' },
  CC33: { fullName: 'Tim Cook', email: 'tcook@gmail.com' }
}
*/

Now what if we try to pass incorrect types for keys or values as follows:

seats.DD24 = "test string";    // Type string is not assignable to type Customer
seats.EE54 = true;        // Type boolean is not assignable to type Customer
seats.a123123 = {fullName: "test customer", email: "tc@email.com"};

In all cases we will get compilation errors.


We are cooking up something great! This post is being updated daily with new info. Bookmark it to get the whole dish once it is finalized.

Did you find this article valuable?

Support Milad's blog by becoming a sponsor. Any amount is appreciated!