Mastering Object-Oriented Programming in TypeScript: Your Complete Guide with Practical Examples
mohammad
Posted on April 27, 2024
Today, we embark on an enjoyable learning experience about Object-Oriented Programming in TypeScript. We’ll break down complex concepts with real-world examples and simplified definitions, paving the way for your coding success.
I’ve been teaching JavaScript and TypeScript for some time, during which I’ve discovered a fun and friendly approach to learn Object-Oriented Programming (OOP). Through this journey, I’ve realized the pivotal role that OOP plays, especially in TypeScript’s context.
So, let’s kick things off by defining Object-Oriented Programming.
Object Oriented programming:
Object Oriented programming (OOP) is a programming paradigm that relies on the concept of classes and objects. It is used to structure a software program into simple, reusable pieces of code blueprints (usually called classes), which are used to create individual instances of objects.
For those unfamiliar with programming paradigms, it’s important to understand that coding should follow a structured approach dictated by specific paradigms and methodologies. Code that lacks structure is often referred to as spaghetti code, making it difficult to comprehend. Object-oriented programming is one such paradigm commonly employed in large-scale websites and applications.
Objects and classes are the foundational data structures used in this programming language. The exciting part is that these data structures have specific properties, attributes, methods, or procedures. Essentially, this means that each object is a unique entity and that objects can interact with each other. This will make more sense later — trust me!
Structures of Object-Oriented Programming:
lets dig deeper and understand the building blocks of OOP
Class:
A class is a blueprint used to create an instance of an object. You can think a class blueprint that our car is going to be built according to it. Class is made up of attributes and methods.
attribute is any piece of data that is declared in a class. it can be thought of as the characteristics or properties of an object. attributes can be instance variables. whats instance variable? I will tell you next line.
Instance variable is a variable that is declared within a class and is associated with each instance or object of that class.
Class Car {
// Attributes (Instance variables)
model: string;
price: number;
color: string;
// methods
drive() {
console.log('The Car has Started driving');
}
stop() {
console.log('The car has stopped');
}
}
Object:
an instance of a class is an object that has properties and methods of the class. back to our example, the car which is created by that blueprint is exactly the object.
const toyota = new Car();
The object tesla is an instance of the Car class. Thus, it contains all the properties present in the Car class. The properties can be accessed and set by object using the dot (.) Operator. for example:
toyota.model = "Camry 2024";
toyota.price = 35000;
toyota.color = "red";
toyota.drive();
Ok, what can be advantages of using class?
Classes are used in object-oriented programming to avoid code duplication, create and manage new objects, and support inheritance.
As you’ve learned, instance variables define the properties of objects within a class, while methods define the behaviors or actions those objects can perform. However, there’s a crucial piece missing: how do we ensure that newly created objects are properly initialized with the correct values? we have constructor function for that.
Constructor Function:
A constructor function is a class function responsible for initializing a class’s instance variables.
A constructor function in programming is like a blueprint for building a specific type of car. Imagine you’re running an automobile manufacturing factory and you want to produce different models of cars. Each car model has its unique characteristics, such as make, model year, color, and features..
In this analogy:
The constructor function is like the blueprint you use to build a car.
The characteristics in blueprint are like the parameters you pass to the constructor function.
The car you build using the blueprint is like the object created using the constructor function.
Let’s say you want to create a constructor function for making Toyota Camry car. Here’s how it might look in TypeScript:
// Define a class for creating cars
class Car {
make: string;
model: string;
year: number;
color: string;
// Constructor function for initializing cars
constructor(make: string, model: string, year: number, color: string) {
this.make = make;
this.model = model;
this.year = year;
this.color = color;
}
}
// Creating a new car object using the constructor function
let myCar = new Car("Toyota", "Camry", 2024, "blue");
// Accessing properties of the car object
console.log(`My car is a ${myCar.year} ${myCar.make} ${myCar.model} in ${myCar.color} color.`);
When a new object is instantiated from Car class, the values of the class’s instance variables are specified as arguments.
Principles of Object-Oriented programming:
There are four fundamental principles of OOP that serve as the building blocks for creating robust and flexible software solutions. These principles, often referred to as the “Four Pillars of OOP,” are:
1. Inheritance:
Inheritance in object-oriented programming refers to a mechanism where a class (subclass) inherits properties from an existing class (superclass).
The subclass can also extend functionality by adding new properties or methods.
Let’s use a simple real-world example to explain inheritance in TypeScript.
Imagine we have different types of vehicles: cars, trucks, and motorcycles. Each of these vehicles shares some common attributes and behaviors, such as the number of wheels, the ability to accelerate, and the ability to honk. However, each type of vehicle also has its unique characteristics.
In this example, we can use inheritance to model the relationship between these types of vehicles. We’ll create a base class called Vehicle, which contains the common attributes and behaviors shared by all vehicles. Then, we'll create subclasses for each specific type of vehicle (Car), which inherit from the Vehicle class and can add their unique features.
// Base class for vehicles
class Vehicle {
protected numberOfWheels: number;
// protected means the property is noy accessible from outside the class.
constructor(numberOfWheels: number) {
this.numberOfWheels = numberOfWheels;
}
accelerate(): void {
console.log("Accelerating...");
}
honk(): void {
console.log("Honking...");
}
}
To inherit from a class (superclass), the extends keyword is used by affixing it after the subclass’s name followed by the superclass’s name.
Note that if the superclass has properties defined in its constructor, the subclass has to initialize these properties in its constructor using the super keyword. The super keyword is used to reference a superclass’ properties in a subclass.
// Subclass for cars
class Car extends Vehicle {
gearBox: string;
constructor(numberOfWheels: number, gearBox: string) {
// Cars typically have four wheels
super(numberOfWheels);
this.gearBox = gearBox;
}
// Additional method specific to cars
park(): void {
console.log("Parking...");
}
changeGear(): void {
console.log(`This is a ${this.gearBox} car`)
}
}
// Create instances of each type of vehicle
const myCar = new Car();
// Example usage
myCar.accelerate(); // Output: Accelerating...
myCar.changeGear("manual"); // Output: This is a manual car
The Car class inherits all the properties present in the Vehicle, like the number of wheels instance variables and the accelerate and honk methods.
Interface:
an interface in TypeScript is a contract that defines the structure of a class. It specifies the properties and methods that a class must implement, but it does not provide any implementation details itself. Interfaces are used to define a common structure that multiple classes can adhere to, enabling polymorphism and facilitating code reuse. we will study polymorphism later.
// Define an interface for a Shape
interface Shape {
// Method to calculate area
calculateArea(): number;
}
// Define a class for Circle implementing the Shape interface
class Circle implements Shape {
// Properties
radius: number;
// Constructor
constructor(radius: number) {
this.radius = radius;
}
// Method to calculate area
calculateArea(): number {
return Math.PI * this.radius ** 2;
}
}
// Define a class for Rectangle implementing the Shape interface
class Rectangle implements Shape {
// Properties
width: number;
height: number;
// Constructor
constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
// Method to calculate area
calculateArea(): number {
return this.width * this.height;
}
}
// Usage
const circle = new Circle(5);
const rectangle = new Rectangle(4, 6);
console.log("Circle area:", circle.calculateArea()); // Output: Circle area: 78.53981633974483
console.log("Rectangle area:", rectangle.calculateArea()); // Output: Rectangle area: 24
in this example we define an interface called shape with a method that returns number. then we implement the shape interface in Circle and Rectangle classes. Each class provides its own implementation of the calculateArea() method based on its specific properties. and in the end We create instances of Circle and Rectangle classes and call their calculateArea() methods to calculate the area of each shape.
Sometimes, a subclass needs to follow a superclass’s implementation but not inherit its properties. These cases require the implements keyword instead of the extends keyword.
Extend vs Implements:
The extends keyword enables the subclass to benefit from inheritance, giving it access to the properties and methods of its superclass.
The implements keyword, however, treats the superclass as an interface, ensuring that the subclass conforms to the shape of its superclass
class Human {
name: string;
gender: string;
constructor(name:string, gender:string){
this.name = name;
this.gender = gender;
}
speak() {
return `I am speaking`;
}
}
class Doctor implements Human {
name: string;
gender: string;
constructor(name:string, gender:string){
this.name = name;
this.gender = gender;
}
speak() {
return 'I am a doctor';
}
}
If the subclass that implements a superclass doesn’t completely mirror its superclass, TypeScript will throw an error. for example, if our Doctor class doesn’t define name or gender instance variables, TypeScript would throw an error.
class Engineer implements Human {
name: string;
constructor(name:string){
this.name = name;
}
speak() {
return 'I am a doctor';
}
}
// Output: Class 'Engineer' incorrectly implements interface 'Human'.
// Property 'gender' is missing in type 'Engineer' but required in type 'Human'.
Extending and Overriding Inherited Properties:
a. Overriding Inherited Properties:
When subclasses inherit properties and methods from their superclass, the inherited properties can be modified or extended. This process of modifying an inherited property is known as overriding.
In the other words, When a subclass (child class) defines a property or method with the same name as a property or method in its superclass (parent class), it is called overriding. This allows the subclass to provide its own implementation for the inherited property or method.
// Parent class
class Animal {
sound: string;
constructor(sound: string) {
this.sound = sound;
}
makeSound(): void {
console.log(this.sound);
}
}
// Child class
class Dog extends Animal {
constructor() {
// Call the parent class constructor
super("Woof");
}
// Override the makeSound method
makeSound(): void {
console.log("Bark! Bark!");
}
}
// Usage
const dog = new Dog();
dog.makeSound(); // Output: Bark! Bark!
In this example, the Dog class overrides the makeSound method inherited from the Animal class to provide its own implementation.
** b. Extending inherited properties:**
When a subclass adds new properties or methods to those inherited from its superclass, it is called extending. This allows the subclass to enhance or extend the functionality provided by the superclass.
// Parent class
class Animal {
legs: number;
constructor(legs: number) {
this.legs = legs;
}
makeSound(): void {
console.log("each animal has its unique sound:");
}
}
// Child class extending Animal
class Dog extends Animal {
breed: string;
constructor(legs: number, breed: string) {
// Call the parent class constructor
super(legs);
this.breed = breed;
}
makeSound():void{
super.makeSound();
console.log("dogs woof");
}
// New method specific to Dog class
bark(): void {
console.log("Woof! Woof!");
}
}
// Usage
const dog = new Dog(4, "Golden Retriever"); // Golden Retriever is a kind of scottish dog
console.log("Number of legs:", dog.legs); // Output: Number of legs: 4
dog.makeSound(); // Output: each animal has its unique sound: dogs woof
dog.bark(); // Output: Woof! Woof!
Deadly Diamond of Death:
Multiple inheritance refers to a subclass inheriting from more than one superclass, this leads to a problem known as the deadly diamond of death.
The deadly diamond of death is a problem that arises when two classes inherit from one superclass, and another class inherits from the child classes that are under the previously created superclass.
class A {};
class B extends A {};
class C extends A {};
class D extends B, C {};// This will throw an error
For context, assume that B and C override a method inherited from A, and then the method is called on an object of D. Which method will be executed? A’s method, B’s method, or C’s method?
The workaround for multiple inheritance is using interfaces instead of classes, so the subclass doesn’t “extend” the superclasses; rather, it “implements” them.For example:
class A {};
interface B extends A {};
interface C extends A {};
class D implements B, C {};
Although, this implementation only ensures that the subclass takes the shape of its superclasses which can be termed polymorphism. It is the most viable solution.
2. Encapsulations:
Encapsulation in object-oriented programming refers to restricting unauthorized access and mutation of specific properties of an object.
You can think encapsulation packing your tools inside a bag that restricts unauthorized access and can be only accessed if you unzip the bag.
Encapsulation helps to achieve data hiding, abstraction, and modularity, making code easier to understand, maintain, and reuse.
In TypeScript, encapsulation is typically implemented using access modifiers such as public, private, and protected to control the visibility of class members (properties and methods).
Access Modifier:
An access modifier is a keyword that changes the accessibility of a property or method in a class.
There are three primary access modifiers in TypeScript:
public: This is the default visibility of every class property. A public property is accessible outside the class.
private: A property prefixed with the private keyword can’t be accessed anywhere outside the class and cannot be inherited by a subclass.
protected: The protected access modifier is very similar to the private access modifier with one key difference. Properties marked with the protected keyword are visible and can be inherited by a subclass.
Here’s an example demonstrating encapsulation in TypeScript:
class Car {
private speed: number;
protected manufacturer: string;
constructor(speed: number, manufacturer: string) {
this.speed = speed;
this.manufacturer = manufacturer;
}
accelerate(): void {
this.speed += 10;
}
getSpeed(): number {
return this.speed;
}
}
class SportsCar extends Car {
private maxSpeed: number;
constructor(speed: number, manufacturer: string, maxSpeed: number) {
super(speed, manufacturer);
this.maxSpeed = maxSpeed;
}
showInfo(): void {
console.log(`Manufacturer: ${this.manufacturer}, Current Speed: ${this.getSpeed()}, Max Speed: ${this.maxSpeed}`);
}
}
const car = new Car(25, "Ford");
const sportsCar = new SportsCar(0, "Ferrari", 300);
console.log(car.speed); // Output: Property 'x' is private and only accessible within class 'Car'.
sportsCar.accelerate(); // Accelerate the sports car
sportsCar.showInfo(); // Output: Manufacturer: Ferrari, Current Speed: 10, Max Speed: 300
Readonly Modifiers:
The readonly modifier is used to define class members (properties) that can only be assigned a value once and cannot be modified thereafter. These members are meant to represent constant values or immutable data.
class Circle {
readonly radius: number;
constructor(radius: number) {
this.radius = radius;
}
calculateArea(): number {
return Math.PI * this.radius * this.radius;
}
}
const circle = new Circle(5);
console.log(circle.radius); // Output: 5
console.log(circle.calculateArea()); // Output: 78.53981633974483
// Error: Cannot assign to 'radius' because it is a read-only property
circle.radius = 10;
In this example, the radius property of the Circle class is declared as readonly, meaning it can only be assigned a value once, in the constructor. Any attempt to modify its value after initialization will result in a compilation error.
Static Class Members:
Properties or methods prefixed with static can only be accessed directly on the class and not on an object instantiated from the class.
In the other words these members aren’t associated with a particular instance of the class. They can be accessed through the class constructor object itself.
class Person {
static species = 'Homo Sapiens';
constructor(public name: string) {}
}
console.log(Person.species); // Output: "Homo Sapiens"
In this example, we have a Person class with a static property called species. To define a static property, we use the static keyword.
To access a static property, you can call it directly on the class, without creating an instance of the class. In our example, we can access the species property like this:
console.log(Person.species); // Output: "Homo Sapiens"
Note that we’re accessing the species property directly on the Person class, without creating an instance of the Person class.
These modifiers help to enforce certain behaviors and constraints on class members, making code more robust and easier to understand.
Initializing Instance Variables with Access Modifiers:
TypeScript provides a shorthand method of initializing instance variables in the constructor. The shorthand method involves declaring the variable once as a parameter in the constructor and prefixing the instance variable with an access modifier.
class A {
constructor(public variable: string){}
}
const object = new A('value')
This method is ideal for classes with a few instance variables as it can quickly get messy and hard to read with multiple instance variables.
Prefixing the properties with specific access modifiers prevents them from being accessed outside the class, which makes it impossible to read or set their values outside the class. This issue is solved using getters and setters, which allow you to read and write inaccessible properties outside the class by implementing accessible methods inside the class.
Setters and Getters:
Getters and setters are special methods used to access and modify the properties of an object in a controlled manner. They provide a way to enforce logic and validation when getting or setting the values of properties.
Getters: A getter is a method that retrieves the value of a property. It allows us to access a property as if it were a regular property, but behind the scenes, it executes a function to compute the value dynamically.
Setters: A setter is a method that allows us to assign a value to a property. It provides a way to control how the value is assigned and perform any necessary validation or processing before setting the value.
Setters and getters are implemented to add some logic between the reading and writing of properties.
In TypeScript, setters are implemented using the set keyword, and getters are implemented using the get keyword.
class BankAccount {
private _balance: number = 0;
get balance(): number {
return this._balance;
}
set balance(amount: number) {
if (amount < 0) {
console.log("Amount cannot be negative.");
return;
}
this._balance = amount;
}
}
// Create an instance of BankAccount
const account = new BankAccount();
// Use the setter to set the balance
account.balance = 1000;
// Use the getter to retrieve the balance
console.log(account.balance); // Output: 1000
// Try to set a negative balance (validation performed in setter)
account.balance = -500; // Output: Amount cannot be negative.
// The balance remains unchanged
console.log(account.balance); // Output: 1000
// Use the setter to set the new balance
account.balance = 450;
// The balance changed
console.log(account.balance); // Output: 450
Encapsulation plays a considerable role in object-oriented programming. It prevents unauthorized access to an object’s properties, giving you better control over properties and methods, thereby increasing code quality and making code easier to maintain.
3. Abstraction:
Abstraction is an important concept in Object Oriented Programming (OOP) as it allows us to hide away implementation details and expose only what is necessary to the user.
We can think abstraction as an ATM machine, even though it performs a lot of action it doesn’t show us the process. It has hidden its process by showing only the main things like getting inputs and giving the output.
In TypeScript, we can create abstract classes and abstract methods to achieve abstraction.
An abstract class cannot be instantiated directly, but it can be inherited by other classes. Abstract classes can have both abstract and non-abstract methods.
Here is an example of an abstract class in TypeScript:
abstract class Animal {
abstract makeSound(): void; // abstract method
move(): void { // non-abstract method
console.log("Roaming the earth...");
}
}
const animal = new Animal() // Cannot create an instance of an abstract class.
In this example, Animal is an abstract class and makeSound is an abstract method. This means that any class that inherits from Animal must implement their own version of makeSound. We can’t instantiate Animal with new because it’s abstract. Instead, we need to make a derived class and implement the abstract members:
class Cat extends Animal {
makeSound() {
console.log("Meow!");
}
}
const myCat = new Cat();
myCat.makeSound(); // Output: "Meow!"
myCat.move(); // Output: "Roaming the earth..."
Here, Cat is a subclass of Animal and implements its own makeSound method. The move method is inherited from the Animal class.
In summary, abstraction in OOP allows us to hide implementation details and expose only what is necessary to the user. In TypeScript, we can achieve this by using abstract classes and methods.
4. Polymorphism:
Polymorphism in object-oriented programming refers to a situation where multiple classes inherit from a parent and override a particular functionality, i.e. the ability of a method or property to exist in different forms.
Polymorphism in real word can be us. Although we are software developers, we can be father, teacher, mother and more.
When you override inherited methods or properties, that’s polymorphism.
class A {
name: string = "Class A"
print(){
console.log('I am class A')
}
}
class B extends A {
name: string = "Class B"
print(){
console.log('I am class B')
}
}
The name property and the print method exist in different forms in each class.
Implementing polymorphism improves code quality and reusability by allowing you to perform the same action differently.
Conclusion:
In this tutorial, you went over the pillars of object-oriented programming:
Inheritance, Encapsulation, Polymorphism, and Abstraction while going into detail about the deadly diamond of death, setters and getters, method overriding, and the implementation of abstract classes.
The importance of object-oriented programming cannot be over-emphasized as it makes maintaining and reusing code very easy.
I hope this blog help someone find OOP easy to understand and learn something. have a blessed time ahead of you.
Posted on April 27, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.