beginner

Object-Oriented Programming(OOP)


Object-Oriented Programming (OOP) is a programming paradigm/style based on classes and objects. OOP allows you to create more organized, modular, and scalable applications. Even if we are using OOP syntax in Javascript, it’s still a prototype oriented language and all of the caveats discussed in previous post can affect our code if we are not careful.

Classes and Objects

In OOP, classes serve as blueprints for creating objects. Objects are instances of classes that encapsulate data and behavior. Think of it like a blueprint to build a car and then each car is an object based on that blueprint. Let’s start with a simple example of defining a class and creating objects from it. We define a class with class keyword. We instantiate a concrete instance of that class by using new keyword.

class Car {
  constructor(make, model) {
    this.make = make;
    this.model = model;
  }

  drive() {
    console.log(`Driving the ${this.make} ${this.model}`);
  }
}

const myCar = new Car("Toyota", "Camry");
myCar.drive(); // Output: Driving the Toyota Camry

A lot is happening in above example, let’s go through it. There is a constructorthat, very simplifed, is a function that will run on every object instantiation(every new Car call is object instantiation). We don’t need to specify it if there is nothing that we want to do immediately upon instantiation. The next thing that might be confusing is this.make and this.model even if we talked about this in previous posts. In Javascript objects we don’t necessarily need to define properties immediately but we can do it using this.<property> and then assign it value. That is exactly what we’re doing in above example and since its done immediately because its in constructor we can use those in our methods.

Caveat in The Above Code

What would happen if we added year and color to constructor? And what about 10 more properties? This is called Telescoping Constructor anti-pattern. Thankfully, there is an easy fix. This approach also handles an unfortunate scenario if you have to rearrange your parameters (because objects are unsorted). Let see how our constructor and initialization would look like after the change:

...
  constructor(data) {
    this.make = data.make;
    this.model = data.model;
    this.year = data.year;
    this.color = data.color;
  }
...
const myCar = new Car({
  make: 'Toyota',
  color: "White",
  year: 2023
  model:'Camry',
});

You remember functions and variables, right? Well, technically, functions are methods and variables are properties when we are talking about classes and objects. But you can use them interchangeably.

Inheritance

Inheritance allows you to create a new class that inherits properties and methods from an existing class. It promotes code reuse and enables you to extend functionality. We use extends keyword to inherit class properties and methods. The class that is inherited is called parent class and class that is inheriting is called child class. Child class must call super function inside its constructor and if there are any required parameters in parent constructor we need to pass those when we call super function in the child class.

class Car {
  constructor(data) {
    this.make = data.make;
    this.model = data.model;
  }

  drive() {
    console.log(`Driving the ${this.make} ${this.model}`);
  }
}

class ElectricCar extends Car {
  constructor(data) {
    super(data); // Even if we have additional property `batteryCapacity` in `data` we can pass it to parent, and it will use only necessary properties
    this.batteryCapacity = data.batteryCapacity;
  }

  charge() {
    console.log(`Charging the ${this.make} ${this.model}`);
  }
}

const myElectricCar = new ElectricCar({
  make: "Tesla",
  model: "Model S",
  batteryCapacity: "100 kWh",
});
myElectricCar.drive(); // Output: Driving the Tesla Model S
myElectricCar.charge(); // Output: Charging the Tesla Model S

Encapsulation

Encapsulation is the practice of bundling data (properties) and methods that operate on the data into a single unit, known as a class. It allows you to hide internal details and control access to properties and methods. There are 3 types of encapsulation public, protected and private. public means the property or method can be accessed from everywhere. protected means the property or method can be accessed within the class and by classes derived(child classes) from that class. private means the property or method can ONLY be accessed within the class.

Important Notes About Encapsulation in Javascript

  • Javascript does not support these keywords but Typescript(⏭️) does
  • In JS everything is public by default
  • Only recently has JS introduced its own version of private. Before that you had to do “hacks” to get properties or methods to be private.
class BankAccount {
  constructor(balance) {
    this._balance = balance; // "Private" property
  }

  getBalance() {
    return this._balance;
  }
}

const account = new BankAccount(1000);
console.log(account.getBalance()); // 1000
console.log(account._balance); // This will return 1000 because nothing is enforcing this property to actually be private

One “hackish” solution is to use closures:

class BankAccount {
  constructor(balance) {
    let _balance = balance;

    this.getBalance = () => {
      return _balance;
    };
  }
}

const account = new BankAccount(1000);
console.log(account.getBalance()); // 1000
console.log(account._balance); // undefined

Thankfully, real private properties were added in ES2022. As of 2023-01-01, private properties (fields and methods) are supported by using #:

class BankAccount {
  #balance;
  constructor(balance) {
    this.#balance = balance;
  }

  getBalance = () => {
    return this.#balance;
  };
}

const account = new BankAccount(1000);
console.log(account.getBalance()); // 1000
console.log(account.#balance); // throws error

Getters/Setters

We can use get and set keywords before function to work with private properties:

class BankAccount {
  #balance;
  constructor(balance) {
    this.#balance = balance;
  }

  get balance() {
    return this.#balance;
  }

  set balance(value) {
    if (value < 0) {
      throw new Error("Value is below 0.");
    }
    this.#balance = value;
  }
}

const account = new BankAccount(1000);
console.log(account.balance); // 1000
account.balance = 5000;
console.log(account.balance); // 5000
account.balance = -5; // throws error

One caveat here is that # properties won’t be available when using JSON.stringify()(JSON is just a text format, ⏭️) on instance of the object. This is expected behaviour but if you have a large object and want to get JSON from it, with # you need to write all those properties individually.

This works:

class Car {
  model;
  constructor(model) {
    this.model = model;
  }
}

const car = new Car("Toyota");
console.log(JSON.stringify(car)); // {"model":"Toyota"}

But with private properties it does not and we need to have a function to do that:

class Car {
  #model;
  constructor(model) {
    this.#model = model;
  }

  toJSON() {
    return JSON.stringify({ model: this.#model });
  }
}

const car = new Car("Toyota");
console.log(JSON.stringify(car)); // {}
console.log(car.toJSON()); // {"model":"Toyota"}

Polymorphism

Polymorphism allows objects of different classes to be treated as objects of a common superclass. Basically, it’s the same named function in different classes with different functionality. It promotes flexibility and abstraction, enabling you to write code that works with multiple types.

class Shape {
  // this exists in case child class doesn't implement this function
  area() {
    return 0;
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  area() {
    return Math.PI * this.radius * this.radius;
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  area() {
    return this.width * this.height;
  }
}

const circle = new Circle(5);
const rectangle = new Rectangle(4, 6);

console.log(`Area: ${circle.area()}`);
console.log(`Area: ${rectangle.area()}`);

const shapes = [circle, rectangle];

shapes.forEach((shape) => {
  console.log(shape.area());
});

Looking at the above example we see that area function in Circle and Rectangle is doing different logic and we can call it with the same name. Would it would be possible to instantiate an object of class Shape. Does such an object exist? The answer is no, there is no object that is simply shape, it must be some of concrete shapes such as circle, rectangle. To not allow us to instantiate such an abstract class there is a keyword abstract. Lets see part of updated code:

Note: abstract keyword is not supported in Javascript, but it is in Typescript(⏭️). Because of this, below example will not work if you try it with Javascript

abstract class Shape {
  area() {
    return 0;
  }
}

class Circle extends Shape {
...

Abstraction

One of the main reasons for OOP is that it provides abstraction on what happens inside the object. It hides unnecessary details of an object’s internal structure. By abstracting an object’s data, its structure and behavior can be kept separate and more easily understood.

Dependency injection

Dependency injection is a design pattern where an object or function makes use of other objects or functions (dependencies) without worrying about their underlying implementation details and further proves the point of why abstraction is important. In other words you can drive but you dont care what object you are driving(car, motorcycle, bicycle) or how it actually drives. Here is an example:

class Vehicle {
  drive() {
    console.log("Concrete implementation needed.");
  }
}

class Car extends Vehicle {
  drive() {
    console.log("Inject fuel...");
    console.log("Check if all wheel drive is on...");
    console.log("Move 4 wheels with transmission...");
  }
}

class Motorcycle extends Vehicle {
  drive() {
    console.log("Inject fuel...");
    console.log("Move rear wheel with transmission...");
  }
}

class Bicycle extends Vehicle {
  drive() {
    console.log("Turn the wheel...");
  }
}

class User {
  operateVehicle(vehicle) {
    vehicle.drive();
  }
}

const car = new Car();
const moto = new Motorcycle();
const bike = new Bicycle();
const user = new User();

user.operateVehicle(car);
user.operateVehicle(moto);
user.operateVehicle(bike);

User doesn’t really know or care what happens inside of each instance(what exactly drive() does for each instance), he only cares that he can operate the vehicle and that it drives.

What now?

Now you know one of the most used programming paradigm/style that we will use almost always from now on. There is a lot that builds on top of what we learned today and we will go through it all in later posts. For now, convert everything that you made in the previous post with POP to OOP. Play around with all of the new things you learned today.

Previous Prototype-Oriented Programming(POP)
Next Example for everything so far (1)