Make a Request
16 min read
The fundamental principles of object-oriented programming in JavaScript

JavaScript is the most popular programming language in the world nowadays and it is for the reason. It is a very flexible and universal language that can be used in various fields, industries and for a variety of tasks. And due to its flexibility already now it can be used for nearly any type of programming: web development (both backend & frontend development), mobile app development, machine learning development, embedded development, etc. Also, specifics of the language let developers apply different paradigms for every particular task just to make sure it solves the problem the best it can. Today we want to look through these paradigms and find out with you how they work in JavaScript.

Object-oriented programming (OOP) is a custom software development pattern that allows you to solve problems in terms of objects and their interactions. OOP is usually implemented using classes or prototypes. Most object-oriented languages (Java, C ++, Ruby, Python, etc.) use class-based inheritance. JavaScript implements OOP through prototype inheritance. In this article, we'll look at both of these approaches in JavaScript, discuss their advantages and disadvantages, and suggest an alternative for developing more modular and scalable applications.

What is an object?

The principle of OOP is to compose a system of objects that solve simple problems, which together constitute a complex program. An object consists of private variable states and functions (methods) that work with these states. Objects have a definition of self (self, this) and behavior inherited from the drawing, i.e. class (class inheritance) or other objects (prototypical inheritance).

Inheritance is a way of saying that these objects are similar to others except for some details. Inheritance can speed development by reusing code.

Class inheritance

In class OOP classes are drawings for objects. Objects (or instances) are created on the basis of classes. There is a constructor that is used to instantiate a class with the specified properties.

Example:

class Person {
constructor(firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}
getFullName() {
return this.firstName + ' ' + this.lastName
}
}

Here, using the class keyword from ES6, we create the Person class with the properties firstName and lastName, which are stored in this. Property values are specified in the constructor, and access to them is done in the getFullName () method.

We create an instance of the Person class called person with the keyword new:

let person = new Person('Dan', 'Abramov')
person.getFullName() //> "Dan Abramov"
// We can use the accessor or get access directly
person.firstName //> "Dan"
person.lastName //> "Abramov"

Objects created with the new keyword are mutable. In other words, changes in the class will affect all objects that are instances of this class, as well as the child classes that extend it (extends).

To extend the class, we can create another class. We extend the class Person using the User class. User is Person with mail and password:

class User extends Person {
constructor(firstName, lastName, email, password) {
super(firstName, lastName)
this.email = email
this.password = password
}
getEmail() {
return this.email
}
getPassword() {
return this.password
}
}

Above, we created the User class, which extends Person's capabilities by adding email and password properties and access functions to them. In the App () function below, we create an object of the new class user:

function App() {
let user = new User('Dan',
'Abramov',
'dan@abramov.com',
'iLuvES6')
user.getFullName() //> "Dan Abramov"
user.getEmail() //> "dan@abramov.com"
user.getPassword() //> "iLuvES6"
user.firstName //> "Dan"
user.lastName //> "Abramov"
user.email //> "dan@abramov.com"
user.password //> "iLuvES6"
}

Everything seems to work fine, but using a class-based approach to inheritance has led to a major design flaw: where can users of the User class (for example, the App) know that this class has the properties firstName and lastName and the function getFullName? A single glance at the User class code is not enough to say anything about its parent class. As a result, you have to dig into the documentation or look for the desired code throughout the class hierarchy.

As Dan Abramov says:

The problem with inheritance is that descendants have too high a level of access to the details of the implementation of each base class in the hierarchy and vice versa. After changing requirements, class hierarchy refactoring is so difficult to carry out that it becomes a complete mess with the traces of obsolete requirements.

Class inheritance is built on the creation of relationships through dependencies. Based on the base classes (or superclasses), derived classes are created. Class inheritance is well suited for small and simple applications that rarely change and that do not have more than one inheritance level (shallow inheritance trees avoids the problem of a fragile base class) or completely different usage scenarios. However, as the hierarchy expands, such inheritance will not be possible to maintain over time.

Eric Elliott described how class inheritance can potentially lead to the failure of the project, and in the worst case - to the failure of the company:

Once you have enough clients using new, you can not even change the implementation of the constructor even if you want, and if you try, you will break all the foreign code.

When many derived classes with very different functions are inherited from one base class, any seemingly innocuous change in the base class can lead to a malfunction in the derivatives. Due to the complexity of the code and the entire process of creating the product, you could mitigate the side effects by creating a container for injecting dependencies. This would provide a single interface for creating services, because it would allow to abstract from the details of the creation. Is there a better way?

Prototypal-inheritance

Prototype inheritance

In prototypical inheritance, classes are not used at all. Instead, objects are created from other objects. We begin with a generalized object - a prototype. The prototype can be used to create other objects by cloning it or to extend it with different functions.

Although in the previous section we showed how to use the class from ES6, the classes in JavaScript are not such classes:

typeof Person //> "function"
typeof User //> "function"

Classes in ES6 are actually syntactic sugar for existing prototype inheritance in JavaScript. Under the hood, when a class is created using the new keyword, a new function object is created with the code from constructor.

In fact, JavaScript is a prototyped-oriented language.

Numbers, strings, boolean variables (true and false), as well as null values and undefined in JavaScript, refer to simple data types. Everything else is objects. Numbers, strings and logical variables are similar to objects in that they have methods, but unlike objects they are unchanged. Objects in JavaScript have mutable key collections. In JavaScript, objects are arrays, functions, regular expressions, and, of course, objects are also objects.

From Douglas Crockford's book "JavaScript: Strengths"

Let's look at one of such objects available in JavaScript "out of the box" - Array.

Arrays (Array instances) are inherited from Array.prototype, which includes many methods that are divided into ancestors (do not change the original array), mutators (change the source array), and iterators (apply the function passed as an argument to each element of the array for creating a new one).

Accessory:

Array.prototype.includes (e) - returns true if the array contains the element e, otherwise it is false.
Array.prototype.slice (i, j) - returns a new array, which is a slice of the source from index i to j inclusive.

Mutators:

Array.prototype.push (e) - puts the element e at the end of the array.
Array.prototype.pop () - removes the last element of the array.
Array.prototype.splice (i, j) - extracts the slice of the array from index i to j inclusive, without saving the original.

Mutators modify the original array. The splice () method takes the same slice as slice (), but if you need to leave the original array, it's better to select slice ().

Iterators:

Array.prototype.map (f) - applies the function f on each element of the array and creates a new array with the result of calling the specified function.
Array.prototype.filter (f) - creates a new array with all the elements that passed the test specified in the function f.
Array.prototype.forEach (f) - applies the function f on each element of the array.
The methods map () and forEach () are similar in that they do something with all elements of the array, but the key difference is that map () returns an array, and forEach () - nothing. In good design practices, software is always recommended to write functions without side effects, i.e. do not use void functions. The forEach () method does not change the original array in any way, so map () is the best choice if you need to somehow convert the data. One possible way to use forEach () is to output to the console for debugging:

let arr = [1,2,3]
arr.forEach(e => console.log(e))
arr //> [1,2,3]

Suppose we want to extend the Array prototype by the new partition () method, which divides the array into two new ones, depending on the predicate. For example, [1,2,3,4,5] becomes [[1,2,3], [4,5]] if the predicate is "less than or equal to 3". Here's how it can be implemented:

Array.prototype.partition = function(pred) {
let passed = []
let failed = []
for(let i = 0; i < this.length; i++) {
if (pred(this[i])) {
passed.push(this[i])
} else {
failed.push(this[i])
}
}
return [ passed, failed ]
}

Now we can apply partition () on any array:

[1,2,3,4,5] .partition (e => e <= 3)
//> [[1, 2, 3], [4, 5]]

[1,2,3,4,5] is called a literal. Literals are one way to create an object. We can also use factory functions or Object.create () to create the same array:*

*// Literals
[1,2,3,4,5]

// Factory function
Array (1,2,3,4,5)

// Object.create
let arr = Object.create (Array.prototype)
arr.push (1)
arr.push (2)
arr.push (3)
arr.push (4)
arr.push (5)*

A factory function is a function that takes multiple arguments and returns a new object consisting of these arguments. In JavaScript, any function can return an object. If it does this without the keyword new, then it can be called factory. Such functions have always been attractive, since they make it possible to easily create new objects without going into the complexity of the classes and the keyword new.

Above, we created the array arr with Object.create () and put 5 elements into it. arr all functions of the Array prototype are available, like map (), pop (), slice (), and even partition (), which we have recently created. Add more functionality to the arr object:

*arr.hello = () => "hello"

arr.partition (e => e <3) // №1

arr.hello () // №2

let foo = [1,2,3]
foo.hello () // №3

Array.prototype.bye = () => "bye"
arr.bye () // №4
foo.bye () // №5*

Answers:
№1 will return [[1,2], [3,4,5]], since the partition () function is defined for the Array from which the arr is inherited.
№2 will return "hello" because we created a new function hello () for the arr object, which takes no arguments and returns the string "hello".
In the case of # 3, the error "TypeError: foo.hello is not a function" will be displayed. Since foo is a new object created from the Array prototype, for which the hello () function is not defined, it is not defined for foo either.
№4 and №5 will be returned "bye", since we added a new function to the Array prototype by bye (), which is inherited by arr and foo. Any changes in the prototype affect the objects on its basis even after they are created.

We figured out the basics of the prototypes, so let's return to the previous example and create Person and User using the prototype inheritance:

unction Person (firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}
Person.prototype.getFullName = function () {
return this.firstName + '' + this.lastName
}

Now we can use the Person prototype in this way:

let person = new Person ('Dan', 'Abramov')
person.getFullName () //> Dan Abramov

person is object. If you enter console.log (person), we'll see the following:

*Person {
firstName: "Dan",
lastName: "Abramov",
proto: {
getFullName: f
constructor: f Person (firstName, lastName)
},
proto: Object
}

For the User, we just need to extend the Person class:

function User (firstName, lastName, email, password) {
// call super constructor:
Person.call (this, firstName, lastName)
this.email = email
this.password = password
}
User.prototype = Object.create (Person.prototype);
User.prototype.setEmail = function (email) {
this.email = email
}
User.prototype.getEmail = function () {
return this.email
}
user.setEmail (atdan@abramov.com ')

user - is an object. If you enter console.log (user), we'll see the following:

User {
firstName: "Dan",
lastName: "Abramov",
email: "dan@abramov.com",
password: "iLuvES6",
proto: Person {
getEmail: f ()
setEmail: f (email)
proto: {
getFullName: f,
constructor: f Person (firstName, lastName)
proto: Object
}
}
}

What happens if we want to change the user's getFullName () function? How does this code affect the person and the user?

User.prototype.getFullName = function () {
return 'User Name:' +
this.firstName + '' +
this.lastName
}
user.getFullName () //> "User Name: Dan Abramov"
person.getFullName () //> "Dan Abramov"

As expected, this did not affect the person in any way.

Let's add a gender attribute to the Person and the corresponding getter and setter:

Person.prototype.setGender = function (gender) {
this.gender = gender
}
Person.prototype.getGender = function () {
return this.gender
}
person.setGender ('male')
person.getGender () //> male
user.getGender () //> returns undefined, although this is a function
user.setGender ('male')
user.getGender () //> male

The changes have affected both the person and the user, since the User inherits from Person, so when the latter changes, User also changes.

The "decorator" pattern from the prototype inheritance is not much different from the class one.

Classes vs Prototypes

Dan Abramov says that:

  • Classes hide the prototype inheritance in the JS framework;
  • Classes encourage the use of inheritance, but it is better to use the composition;
  • Classes, as a rule, do not allow you to change the first bad structure of the project that came to your mind.

Instead of class hierarchy, it's best to create several factory functions. They can call each other on a chain, adjusting their behavior. Also, you can teach the "main" factory function to take a "strategy" that tunes the behavior of other factory functions, and transfer it from the rest of the factory functions.

No-OOP-for-javascript-

The third way: without the OOP

The three cornerstones of OOP - inheritance, encapsulation and polymorphism - are powerful tools / concepts, but with their shortcomings.

Inheritance

Inheritance helps to reuse code, but often you have to take more than necessary.

Joe Armstrong (the creator of Erlang) expressed the idea of this in the best way:

The problem of object-oriented languages lies in their entire implicit environment, which they always pull for themselves. You wanted a banana, and got a gorilla holding a banana, along with all the jungle.

So what if we got more than asked? Just ignore what we do not need? Only in simple cases. If we need classes that depend on other classes, and those in turn depend on third classes, then we have to deal with all this hell of dependencies, which greatly slows down the build and debug processes. In addition, applications with such long chains of dependencies are poorly ported.

Here there is the problem of the fragile base class mentioned above. Do not expect that everything will go like clockwork, when we correlate real objects and their classes. Inheritance will not be condescending to you when you have to refactor the code, especially the base class. It also weakens encapsulation, another cornerstone of the OOP:

The problem is that if you inherit the implementation of a superclass, and then change it, then these changes echo throughout the class hierarchy. Ultimately, this can affect all subclasses.

Encapsulation

Encapsulation protects against internal influences the internal variables of each object. Ideally, the program should consist of "islands of objects": each of them with its own states, transmitting messages back and forth. It sounds like a good idea if you create an ideally distributed system, but in practice the development of such a program is complex and drives into certain limits.

Many real-world applications require the solution of problems with many components. When you choose an object-oriented approach to program development, you will encounter different puzzles such as "how to distribute application functionality between different objects?" Or "how to manage interaction and data exchange between different objects?". In this article, there are several interesting thoughts about the tasks involved in designing OOP applications:

When we consider the necessary functionality of our code, many of the behaviors are inherently common problems and therefore do not belong to any particular type of data. Nevertheless, these settings need to be placed somewhere, so in the end we create meaningless classes for their content. All these senseless entities have a habit of becoming even more meaningless: when I have many Manager objects, I have to create a ManagerManager.

And in fact and is. Such ManagerManager classes can often be seen in production, which, according to the idea, should not become so complex over time.

Next, we'll see an alternative to OOP - a functional composition, where functions are used instead of objects.

But before this we'll talk about the last cornerstone of the OOP.

Polymorphism

Polymorphism allows you to describe the behavior, regardless of the type of data. In OOP, this means creating a class or prototype that can be adapted by objects that work with other types of data. Objects that use a polymorphic class / prototype must define a data type-specific behavior to make everything work. Let's look at an example.

Suppose that we want to create a common (polymorphic) object that takes some data and a status flag as parameters. If the state says that the data are valid (i.e., status === true), a function can be applied to the data, the result of which will be returned together with the status flag. Otherwise, we will not apply the function and just return the data and the flag.

Let's start with creating a polymorphic prototype object Maybe:

function Maybe ({data, status}) {
this.data = data
this.status = status
}

Maybe is a wrapper for data. To wrap them, we added a status field, which indicates the validity of the data.

We can add the function apply (), which takes a function and applies it to the data, if the status says that they are valid:

Maybe.prototype.apply = function (f) {
if(this.status) {
return new Maybe({data: f(this.data), status: this.status})
}
return new Maybe({data: this.data, status: this.status})
}

We can also add a function that returns either data or a message if something is wrong with them:

Maybe.prototype.getOrElse = function (msg) {
if(this.status) return this.data
return msg
}

Now let's create two Maybe objects based on Number object:

function Number(data) {
let status = (typeof data === 'number')
Maybe.call(this, {data, status})
}
Number.prototype = Object.create(Maybe.prototype)

And String:

function String(data) {
let status = (typeof data === 'string')
Maybe.call(this, {data, status})
}
String.prototype = Object.create(Maybe.prototype)

Let's look at the objects in action. Let's create an increment () function that is defined only for numbers, and split (), which is defined only for strings:

const increment = num => num + 1
const split = str => str.split('')

Since JavaScript is not type-safe, you will not be banned from using increment () for a string or split () for a number. You will just see a performance error. For example:

let foop = 12
foop.split('')

At startup, you'll get a TypeError.

However, if we use the Number and String objects to wrap numbers and strings before working with them, we can prevent these errors:

*let numValid = new Number(12)
let numInvalid = new Number("foo")
let strValid = new String("hello world")
let strInvalid = new String(-1)

let a = numValid.apply(increment).getOrElse('TypeError!')
let b = numInvalid.apply(increment).getOrElse('TypeError Oh no!')
let c = strValid.apply(split).getOrElse('TypeError!')
let d = strInvalid.apply(split).getOrElse('TypeError :(')*

What would you get in console?

console.log({a, b, c, d})

Since we described the Maybe prototype so that the function is applied to the data of the correct type, the result will be:

{
a: 13,
b: 'TypeError Oh no!',
c: [ 'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd' ],
d: 'TypeError :('
}

We just did something like a monad (although we did not implement Maybe on all the laws of monads). The Maybe monad is a wrapper that is used when data may not be validated or absent, and you do not care for what reason. Typically, this happens when retrieving and verifying data. Maybe handles errors when validating data or applying a function similar to the try-catch way. Here all the processing is output to the console, but we can easily change the function getOrElse () so that it calls another handler function.

In some languages like Haskell, a monad is a built-in type, but in JavaScript you have to create your own implementation. In ES6 appeared Promise - monads for work with a delay. Sometimes we need data that takes time to get. Promise allows you to write synchronous code, putting off work with data until the moment when they become available. Using Promise is a more "pure" way of asynchronous programming than callback functions, the use of which can lead to a situation known as "hell of callbacks".

Composition

As mentioned earlier, there is something much simpler than the classes / prototypes - the functional composition. It can easily be used again, it encapsulates internal states, performs operations on any type of data, and can be polymorphic.

JavaScript makes it easy to combine related functions and data in an object:

const Person = {
firstName: 'firstName',
lastName: 'lastName',
getFullName: function () {
return $ {this.firstName} $ {this.lastName}
}
}

Now we can use the Person object in this way:

*let person = Object.create (Person)

person.getFullName () //> "firstName lastName"

// Assign values to internal state variables
person.firstName = 'Dan'
person.lastName = 'Abramov'*
// Get access to them
person.getFullName () //> "Dan Abramov"

Create a User object by bending the Person object, and add additional data and functions to it:

const User = Object.create (Person)
User.email = ''
User.password = ''
User.getEmail = function () {
return this.email
}

Then we can create a User instance with Object.create ():

let user = Object.create (User)
user.firstName = 'Dan'
user.lastName = 'Abramov'
user.email = 'dan@abramov.com'
user.password = 'iLuvES6'

The trick here is to use Object.create () to copy. Objects in JavaScript are mutable, so when you use the assignment to create a new object and change the second object, this changes the original object!

Except for numbers, strings, and Boolean values in JavaScript, everything is an object:

// Wrong
const arr = [1,2,3]
const arr2 = arr
arr2.pop ()
arr //> [1,2]

Here we used the keyword const to show that it does not protect you from changing objects. Objects are defined by their reference, so although const does not allow to reassign arr, you can still change it.

To make sure that we do not pass the object reference, but copy it, we use Object.create ().

As with Lego cubes, we can create copies of the same object, customize them, combine and transfer them to other objects to increase their capabilities.

As an example, we define the Customer object with data and functions. When our user becomes the Customer, we want to add everything that Customer has to the user object:

const Customer =
plan: 'trial'
}
Customer.setPremium = function () {
this.plan = 'premium'
}

Now we can add the Customer methods and fields to the user object:

User.customer = Customer
user.customer.setPremium ()

After executing these two lines, the user object will look like this:

*{
firstName: 'Dan',
lastName: 'Abramov',
email: 'dan@abramov.com',
password: 'iLuvES6',
customer: {plan: 'premium', setPremium: [Function]}
}
*

When we want to add even more features, higher-level objects will always help us with this.

As shown in the example above, class composition should be preferred to class inheritance, since it is simpler, more expressive, and more flexible.

Conclusion

Programmers often have to find a trade-off between reusing code and its scalability. Probably, the use of class OOP makes sense for corporate software, as it does not change much. The behavior in OOP is clearly written in abstract classes, but it can be customized to some extent during the creation of instances of the class. This facilitates better code reuse, which saves developers a lot of time.

Nevertheless, if you expect that in the future many times you have to supplement the code and even revise the project, then OOP will eventually interfere with developer productivity and the code will become unreadable and strongly connected with the environment.

Have a project?
We are ready to help!
Discuss Image
Related Posts
You were wrong: AI vs Machine Learning what is the real difference!
Although often times used interchangeably, you might actually artificial intelligence and machine learning is not exactly the same despite the prevailing trends to merge the two. The reality might seem slightly blur for
The fundamental principles of object-oriented programming in JavaScript
JavaScript is our favorite programming language because you can develop any software with it. In this article, we want to tell you about object-oriented programming in JavaScript with which you can develop any custom software in the world!
Top Blockchain Development Certifications for Developers
With the blockchain industry evolution blockchain development is taking a lead among other software development types. That is why we've gathered the list of top blockchain development certifications for those who want to dive deeper in the distributed world.