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:
Public
Private
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.