Photo by Glenn Carstens-Peters on Unsplash
A complete guide to Object-Oriented Programming (OOP) in JavaScript.
Harnessing the power of classes: The classical approach to OOP.
Introduction
This article contains the core concepts of OOP. Everything you need to understand this powerful paradigm is here. You’ll explore concepts like encapsulation, polymorphism, and inheritance.
Prerequisite
To get a good read of this article, you should understand the basics of JavaScript.
What is Object-Oriented Programming (OOP)?
Object-oriented programming, popularly called OOP, is a famous programming paradigm recognized by many languages like Python, Java, and many more. It involves modeling a system as a collection of objects; each object represents a particular aspect of the system. Objects possess properties (data) and behaviors (methods). There is a common saying. Everything is an object in JavaScript. Well, that is inaccurate; we will get to that. A perfect analogy for an object would be a human. Humans have properties, for example, names, ages, strengths, etc. We also have behaviors, things we can do, for example, move around, create, build, and recreate. That is the object model design, possessing properties and executing methods (its behavior) when invoked
Why OOP?
Why should you choose object-oriented programming over the procedural method (functional programming)? Here are a few reasons:
Modularity: OOP enforces the organization of code into reusable, independent modules called classes. Each class encapsulates a set of related data and methods, making managing and maintaining large codebases easier.
Encapsulation: Encapsulation is the concept of grouping data (attributes) and the methods (functions) that operate on that data into a single unit (class). Encapsulation protects properties and methods from unauthorized access and modification. This concept is often used in information hiding, to hide an object’s internal representation or state from outsiders.
Abstraction: OOP allows you to abstract complex systems into simple representations.
Inheritance: OOP supports inheritance, a class inheriting from another class; this resembles a parent and child model, with children inheriting some properties from parents. This concept ensures code reusability.
Polymorphism: This concept explains that there can be multiple forms of a single method, and depending on runtime, one type of object can have different behavior.
Pitfalls of OOP
OOP can be complex. It would be overengineering to use it when solving simple problems.
Testing and debugging can get harder due to object and class interactions.
It requires a certain level of skill to implement. It also has a steeper learning curve than conventional approaches.
OOP can sometimes lead to complex code.
Object and object literals
What is an object?
We already understand the concept of OOP, so what is an object? They are almost like objects in real life, having some properties and the ability to exhibit certain behaviors (methods). An object can have properties and methods. An object is a collection of related data and/or functionality.
Object literal notation
One way of creating objects is through object literals, which are essentially collections of key-value pairs separated by commas. For example:
const person = {
name: 'James',
age: 25,
school: 'University of Lagos',
}
We use curly braces for this notation, storing the object in a variable.
We can access properties in the object with dot notation or square bracket notation.
console.log(person["age"]) //result is 25
console.log(person.name) //result is ‘James’
This way of creating objects is referred to as an object literal method. Let’s add some methods (behavior) to this object.
name: 'James',
age: 25,
school: 'University of Lagos',
foodlev: 0,
eat() {
this.foodlev += 50
console.log(this.foodlev)
},
Work(){
this.foodlev -= 50
console.log(this.foodlev)
}
}
person.eat() //result is 50
person.Work()//result is 0
We added an eat method, a function a person can execute. The “this” keyword in an object literal refers to the object itself, so we can access the object properties in the methods by referencing “this”. We increment the foodlev property in the eat method by 50, then log the property to the console. In the work method, the foodlev property is decreased by 50. This is just to demonstrate the model behavior of a person.
Everything behaves like an object
Everything in JavaScript can behave like an object. For example, an array of data has properties like length and methods like foreach, map, filter, etc. An array is an object; to demo this, let's take an example.
let names = ["juwon", "dave", "austin", "lambo"];
console.log(names.length); //property
names.forEach((name) => console.log(name, "haha")); //foreach method
output:
Here, the 'forEach' method is used on the array object. There is another way of creating array objects without using square brackets. We use the “new” keyword:
let names = new Array("juwon", "dave", "Austin", "Lambo");
What follows the new keyword is the constructor; we will get to that shortly. However, the result is still the same as before.
Everything in JavaScript is not an object. Primitive data types like strings, Booleans, and numbers are not objects. But they can behave like objects. When we try to access a property or a method on a primitive data type, it takes the value and wraps it in a wrapper object; the wrapper object has those methods.
To explore this, we would be working with the console. When you create a string and store it in a variable, it has no properties or methods.
let str = 'a string'
output:
As we can see, there are no properties or methods. This is a string literal. When we try to access the known string methods, it still works because the string will get wrapped in a wrapper object. However, if we create a string with the string constructor, it gets created as an object.
let str2 = new String('a string2')
output:
Null and undefined are not considered objects; they have no wrapper objects, so they can’t access any properties or methods. However, if you log the type of null to the console, it returns an object. Why? It stems from what is often considered a historical bug in the JavaScript-type system. Null is still a useful concept.
console.log(typeof(null)) //returns object
Even though everything in JavaScript is not an object, everything can behave like an object when wrapped in a temporary object wrapper.
Classes
Object literals are not the only way of creating objects; they are not the go-to when creating multiple versions of the same object. We can use a class.
It is important to note that this class syntax is just synthetic sugar, easier to work with than the prototypal method of working with objects, which works under the hood in classes.
A class resembles a blueprint, holding the information that describes objects in a certain way. For example, a phone blueprint can have color, storage, and size properties. This blueprint is used to create different phones (instances). These instances will have unique properties but fall into the same categories (color, storage, size). The first phone might be blue, 64 GB, and 17 inches; the second phone might be green, one terabyte, and 22 inches. These different phones are called instances of that class. Take a look at this illustration.
This illustration describes a user class used to create two different instances. These instances have access to the methods defined in the class; for example, user one can call the login method on itself.
Class syntax
To define classes in JavaScript, we use the class keyword. The convention says you should capitalize the first letter of the class name.
class Car{
}
To instantiate a new car, we use the ‘new’ keyword. This keyword will call the car constructor method.
const car1 = new Car();
The constructor method
We need to create a constructor method in the class that constructs the instances of our object. We have an inbuilt method for this called “constructor”; we describe the object in it. This constructor method does three things:
Creates a new empty object.
Sets the value of “this” to the empty object so you can access it in your constructor code.
Runs the code in the constructor method. Which specifies the properties of the new instance.
Returns the new object.
If no property gets passed when instantiating the new car, the properties would have to be hard-coded in the constructor like this:
class Car{
constructor(){
this.color = 'blue'
this.engine = 'v6'
this.length = '19m'
}
}
const car1 = new Car();
console.log(car1)
We are referencing “this” in the constructor; it would set properties to the new empty object created for a car instance, e.g., car1. However, we are hard-coding the properties for every car instance. To rectify this, pass the properties when instantiating a new car instance. Like this:
class Car{
constructor(color,storage,size){
this.color = color
this.storage = storage
this.size = size
}
}
const car1 = new Car('cyan', 'v6', "19meters");
console.log(car1)
We accept them as arguments in the constructor method, so we can set the values dynamically in the new object.
We can now have as many car instances or objects as we like.
const car1 = new Car('cyan', 'v6', "19meters");
const car2 = new Car('blue', 'v8', "12meters");
const car3 = new Car('red', 'flat engine', "23meters");
console.log(car1)
console.log(car2)
console.log(car3)
output:
Class methods
Implementing custom methods in classes
We have been able to describe properties for object instances. How about the behavior? Implementing custom methods is easy with this classical approach to OOP. We define the custom method after the constructor.
class Car{
constructor(color,storage,size,name){
this.color = color
this.storage = storage
this.size = size
this.name = name
}
drive(){
console.log(`${this.name} drove 20km`)
}
}
const car1 = new Car('cyan', 'v6', "19meters", 'fordv1');
const car2 = new Car('blue', 'v8', "12meters", 'fordv2');
const car3 = new Car('red', 'flat engine', "23meters", 'fordv3');
In the drive method, we logged the name of the car and some text. We have access to ‘this’ in all methods.
We can call the method on each instance of the car class.
car2.drive()
car1.drive()
output:
It is important to note that a default constructor will be generated if there is none. However, we cannot create instances with different properties like this, only hard-coded properties.
class Car {
name = "fordv10";
drive() {
console.log(`${this.name} drove 20km`);
}
}
const car = new Car();
console.log(car);
car.drive();
Method chaining
We must have seen examples of methods tacked on to a single object before. Method chaining involves invoking different object methods consecutively on a single instance. Let’s see an array example:
[1,23,45].map().filter().reduce()
We are using three different methods on a single array object; this is what method chaining is all about.
To demo this in our classical approach, let’s add more methods to our car class.
constructor(color,storage,size,name){
this.color = color
this.storage = storage
this.size = size
this.name = name
}
drive(){
console.log(`${this.name} drove 20km`)
}
reverse(){
console.log('reverse')
}
break(){
console.log('hold')
}
}
We have added two more methods, drive and reverse; now let’s try to chain those methods together on a single instance.
const car1 = new Car('cyan', 'v6', "19meters", 'fordv1');
car1.drive().reverse().break()
output:
We get an error; why? The drive method does not return any value, so the reverse method doesn’t work. These methods do not return any values. To rectify this, we need to return “this” in every method; “this” refers to the object itself, which has all the methods, so chaining is now possible.
lass Car{
constructor(color,storage,size,name){
this.color = color
this.storage = storage
this.size = size
this.name = name
}
drive(){
console.log(`${this.name} drove 20km`)
return this
}
reverse(){
console.log('reverse')
return this
}
break(){
console.log('hold')
return this
}
}
const car1 = new Car('cyan', 'v6', "19meters", 'fordv1');
car1.drive().reverse().break()
output:
We now have an output.
Class inheritance
A class can inherit from another class. Inheritance is a unique concept in OOP.
When a class inherits from another class, it gets all the properties and functionality from that class; we can also add extra functionality.
To demo this, we would be creating a user and admin class. The admin class inherits from the user class. Let’s take a look at the user class:
class User{
constructor(name,age,email){
this.name = name
this.age = age
this.email = email
}
login(){
console.log('logged in')
}
logout(){
console.log('logged out')
}
}
Now, the admin class should inherit from the user class. This concept can be implemented by using the 'extends' keyword, followed by the parent class name.
class Admin extends User{
adduser(){
console.log('added user')
}
deleteuser(){
console.log('deleted user ')
}
}
const admin1 = new Admin('juwon', 200, 'j@gmail.com')
When a constructor is not in the admin class, it uses the constructor from the user class(parent).
const admin1 = new Admin('juwon', 200, 'j@gmail.com')
admin1.login() //result is logged in
admin1.adduser() // result is added user
Admins can access methods from the user class, e.g., the login method. They can still have extra methods, like an "add users" method.
Super method
To add additional properties to the admin class. We must define a constructor for it.
Since we are not using the parent constructor, we need to find a way to add the initial properties like name, age, email, and other additional properties; to do this, we use the super method.
class Admin extends User{
constructor(name,age,email, department){
super(name,age,email,department)
this.department = department
}
adduser(){
console.log('added user')
}
deleteuser(){
console.log('deleted user ')
}
}
const admin1 = new Admin('juwon', 200, 'j@gmail.com', 'marketing')
admin1.login()
admin1.adduser()
The super method passes its value to the parent constructor, the user class. We can now add extra properties by just referencing “this”.
Note: The super method is required when creating a subclass constructor; if not specified, the ‘this’ keyword would not work.
The super method is used to call methods from a parent class. Let’s see an example:
class Greet {
sayHello() {
console.log("Hello from Parent!");
}
}
class Child extends Greet {
sayHello() {
super.sayHello(); // Call the parent class method
console.log("Hello from Child!"); // specific behaviour(polymorphism)
}
const child = new Child();
child.sayHello();
// Outputs:
// Hello from Parent!
// Hello from Child!
We call the method name on the super object. Super is mainly used for those two purposes: calling parent constructors and their methods.
Polymorphism
Definitions of polymorphism
This concept is crucial in OOP. “Poly” means many, and “morphism” means to change form or transform. A good example would be humans behaving differently in different situations; this demonstrates the concept properly. Object methods can behave differently.
Polymorphism refers to the concept that there can be multiple forms of a single method, and depending on runtime, one type of object can have different behavior. Polymorphism utilizes inheritance for this purpose.
Polymorphism allows different classes to provide their implementations of inherited methods.
Method overriding
Method overriding is a type of polymorphism that allows a child class to implement methods in the parent class differently.
To achieve polymorphism classically, we use method overriding via inheritance; let’s take a look:
Note: Other types of polymorphism will not be discussed in this article.
class User{
constructor(name,age,email){
this.name = name
this.age = age
this.email = email
}
login(){
console.log('logged in')
}
logout(){
console.log('logged out')
}
}
class Admin extends User{
constructor(name,age,email, department){
super(name,age,email)
this.department = department
}
logout(){
console.log('admin logged out')
}
}
const admin1 = new Admin('juwon', 200, 'j@gmail.com', 'marketing')
admin1.login() //result is “logged in”
admin1.logout() //result is “admin logged out”
Referencing our former example, the user class has a logout method; we override that method in the child class (Admin). That is what polymorphism is about. Using inherited methods but "Overriding" them to do something different.
Polymorphism aids code reusability and abstraction.
Encapsulation
Another crucial concept to remember is encapsulation. Encapsulation in OOP means bundling up data and methods that work on that data into a single unit, called a class; this is all we have been doing (encapsulating into classes). Without encapsulation, we can access object properties from outside its class. Like this:
class Laptops{
name
constructor(name){
this.name = name
}
program(){
console.log(`output is picture: `, this.name)
}
write(){
console.log(`microsoft word: `, this.name)
}
}
const hp = new Laptops('Hp')
console.log(hp.name) //result is Hp
We can log the name property outside; this is not a secure code. To restrict access, we make the property private by prepending the “#” keyword.
For example:
class Laptops{
#name
constructor(name){
this.#name = name
}
program(){
console.log(`output is picture: `, this.#name)
}
write(){
console.log(`microsoft word: `, this.#name)
}
}
const hp = new Laptops('macbook')
console.log(hp.#name)
Now that the name property is private, JavaScript enforces the restriction to the property from outside the class, hence an error.
Output:
Encapsulation is a fundamental concept in OOP that enhances data security, code maintainability, abstraction, and flexibility while promoting good software engineering practices.
Private methods
We can also have private methods, methods inaccessible outside the class. It hides the inner functionality of the class from the outside. Class instances can’t access these types of methods. We have to prepend the method with a “#” keyword, let’s take a look:
class Laptops{
#name
constructor(name){
this.#name = name
}
program(){
console.log(`output is picture: `, this.#name)
}
write(){
console.log(`microsoft word: `, this.#name)
}
#memory(){
return '12312323bytes'
}
}
const hp = new Laptops('macbook')
console.log(hp.#memory())
The memory method returns a string. We get an error when we attempt to access this method outside because it is private. Private methods can only work within its class. A great use case is to execute and return a value used in another method in the same class. To access a private method within the class, reference the “this” keyword. Let’s take a look:
class Laptops {
#name;
constructor(name) {
this.#name = name;
}
program() {
console.log(`output is picture: `, this.#name);
}
write() {
console.log(`microsoft word: `, this.#name);
}
#memory() {
return "12312323bytes";
}
ram() {
console.log(`${this.#memory()}`);
}
}
const hp = new Laptops("MacBook");
hp.ram()
output:
This concept of private methods and properties is used in abstraction. A process that involves exposing only the necessary methods and hiding the inner workings from the user.
It should be noted that private class elements are a recent addition to JavaScript (introduced in ES2022), and not all JavaScript environments fully support them yet**.**
Bonus
Stating the differences between the following terms would help clarify the remnant confusion in your mind; this article has explained all these terms.
A reiteration:
From MDN docs: An object is a collection of properties, and a property is an association between a name (or key) and a value. A property value can be a function. In this occurrence, it is called a method.
An object instance is a specific occurrence of an object.
A difference between a function and a method is that functions are independent and not declared within classes. Functions defined in classes are called methods.
Conclusion
That’s all folks. I hope you learned a few. If you followed through, you should now understand one of the most popular paradigms in the industry. You can read more via the additional resources. Thank you.
Please throw me a like and a comment😊.