DEV Community

Cover image for Object-Oriented JavaScript: More Than Just Classes
raphiki for Technology at Worldline

Posted on • Updated on

Object-Oriented JavaScript: More Than Just Classes

I stumbled upon this article I wrote in French precisely 15 years ago and I find it's still relevant. Despite its popularity, JavaScript is a language that deserves to be better known and acknowledged. So, I decided to translate it into English and republish it after a slight brush-up!

This article assumes you're familiar with JavaScript syntax. Note that I'm not discussing the class-based object orientation introduced in ECMAScript 6, but rather the native object orientation that has been present from the beginning in the language, based on the prototype concept.

So, let's take a deep breath and dive into the mysterious world of objects, prototypes, and closures!

Note:
The print() function used in the examples is not part of the ECMAScript standard. In console mode, it displays text on the standard output. In a browser, it could be replaced by Document.write() or alert(). In Node.js you could use console.log().

Genuine Object Pieces Inside...

Objects

JavaScript is fundamentally built around the concept of objects. Everything in it is an object or a reference to an object. For example, arrays, types, or even functions are objects. Objects contain members, called properties, in the form of (name, value) pairs. Property values can be strings, numbers, booleans, or other objects (including arrays and functions).

Let's start with a simple example to represent my dog Rex:

// My dog Rex
var myDog = new Object();
myDog.name = "Rex";
myDog.gender = "Male";
print(myDog.name);
print(myDog.gender);
Enter fullscreen mode Exit fullscreen mode

Executing the above code returns:

Rex
Male
Enter fullscreen mode Exit fullscreen mode

We can also declare and manipulate an object like an associative array:

// Declaration as an array
var myDog = new Array();
myDog["name"] = "Rex";
myDog["gender"] = "Male";
print(myDog["name"]);
print(myDog["gender"]);
Enter fullscreen mode Exit fullscreen mode
Rex
Male
Enter fullscreen mode Exit fullscreen mode

myDog is still an object, and its properties can be accessed like array elements.

There's also a shorthand declaration for JavaScript objects:

// Shorthand declaration
var myDog = {name: "Rex", gender: "Male"};
print(myDog.name);
print(myDog["gender"]);
Enter fullscreen mode Exit fullscreen mode
Rex
Male
Enter fullscreen mode Exit fullscreen mode

This notation has been adopted by the JSON (JavaScript Object Notation) serialization format, which allows for the exchange of structured data over the network. It's often used as an alternative to the XML format by AJAX web applications for asynchronous client-server communications.

Now, let's give Rex the ability to speak:

myDog.bark = function() {
  return "Woof!";
}
print(myDog.bark());
Enter fullscreen mode Exit fullscreen mode
Woof!
Enter fullscreen mode Exit fullscreen mode

In JavaScript, since functions are objects, it's possible to declare them as properties of other objects, thus creating methods. However, note that JavaScript doesn't differentiate between an object's properties, whether they're attributes or methods.

Constructors and Object Types

Now, imagine I also own a female dog named Mirza and want to represent her in JavaScript. We could replicate the previous declarations, but ideally, we'd reuse a common structure for both Rex and Mirza, i.e., create an object type Dog.

JavaScript allows this through the use of a special function called a constructor, as follows:

// Dog object constructor function
function Dog(name, gender) {
  this.name = name;
  this.gender = gender;
  this.bark = function() {
    return "Woof!";
  }
}
// Creating new Dog type objects
var myDog = new Dog("Rex", "Male");
var myFemaleDog = new Dog("Mirza", "Female");
print(myDog.name);
print(myFemaleDog.gender);
Enter fullscreen mode Exit fullscreen mode
Rex
Female
Enter fullscreen mode Exit fullscreen mode

Let's clarify two keywords used in the above example:

  • new is the object creation operator. When applied to a function, that function is used as a constructor to create the new object.
  • this, within the constructor, refers to the object being created. So, this.name corresponds to a property of the object created by the Dog constructor, to which the value of the name parameter passed to the function is assigned.

Although JavaScript handles primitive types (retrievable via the typeof operator), it's more useful to consider that an object's type is the name of its constructor (retrievable via the constructor.name property). According to this perspective, the types managed by JavaScript are as follows:

  • Object: generic, untyped object;
var myObject = {firstName: "Joe", lastName: "Black"};
print(myObject.constructor.name);
Enter fullscreen mode Exit fullscreen mode
Object
Enter fullscreen mode Exit fullscreen mode
  • Boolean: boolean (either true or false);
var myBoolean = (1 == (2-1));
print(myBoolean.constructor.name);
Enter fullscreen mode Exit fullscreen mode
Boolean
Enter fullscreen mode Exit fullscreen mode
  • Number: integer or decimal number;
var myNumber = 1;
print(myNumber.constructor.name);
Enter fullscreen mode Exit fullscreen mode
Number
Enter fullscreen mode Exit fullscreen mode
  • String: string of characters;
var myString = "Hello";
print(myString.constructor.name);
Enter fullscreen mode Exit fullscreen mode
String
Enter fullscreen mode Exit fullscreen mode
  • Array: array;
var myArray = [1, "brt"];
print(myArray.constructor.name);
Enter fullscreen mode Exit fullscreen mode
Array
Enter fullscreen mode Exit fullscreen mode
  • Function: function containing a code segment;
var myFunction = function() {
  return "hi there";
} 
print(myFunction.constructor.name);
Enter fullscreen mode Exit fullscreen mode
Function
Enter fullscreen mode Exit fullscreen mode
  • [ConstructorName]: type defined by the user via a constructor function.
function Dog(name, gender

) {
  this.name = name;
  this.gender = gender;
  this.bark = function() {
    return "Woof!";
  }
}
var myDog = new Dog("Rex", "Male");
print(myDog.constructor.name);
Enter fullscreen mode Exit fullscreen mode
Dog
Enter fullscreen mode Exit fullscreen mode

By the way, let's recall that JavaScript is a dynamically typed language. This means that it doesn't know in advance the type of a variable (or rather, the object referenced by a variable). It relies on the value of the variable to determine its type and is able to convert the type of a variable based on context.

For instance:

var monNombre = 34;
var maChaine = "5";
print(monNombre + maChaine);
Enter fullscreen mode Exit fullscreen mode
345
Enter fullscreen mode Exit fullscreen mode

Encapsulation

Although JavaScript is an interpreted language, it allows the implementation of the encapsulation principle by distinguishing several levels of visibility:

  • Public properties: By default, the components (attributes or methods) of an object are accessible to everyone.
function Object1() {
  this.attribute = "Public attribute";
}
Object1.prototype.function = function() {
  return "Public method";
}
var MyObject1 = new Object1();
print(MyObject1.attribute);
print(MyObject1.function());
Enter fullscreen mode Exit fullscreen mode
Public attribute
Public method
Enter fullscreen mode Exit fullscreen mode
  • Private properties: Properties created in the constructor are only accessible to the object's private methods.
function Object2() {
  var attribute = "Private attribute";
  function function() {
    return "Private method";
  }
}
var MyObject2 = new Object2();
print(MyObject2.attribute);
try {
  print(MyObject2.function());
} catch(e) {
  print(e);
}
Enter fullscreen mode Exit fullscreen mode
undefined
TypeError: MyObject2.function is not a function
Enter fullscreen mode Exit fullscreen mode
  • Privileged methods: Methods that can access the object's private properties while being accessible from outside.
function Object3() {
  var attribute = "Private attribute";
  this.function = function() {
    return(attribute);
  };
}
var MyObject3 = new Object3();
print(MyObject3.function());
Enter fullscreen mode Exit fullscreen mode
Private attribute
Enter fullscreen mode Exit fullscreen mode

Namespaces

As we have seen, the default execution context of the script is associated with the Global scope. Thus, all objects and all references declared outside of functions are global properties. As this can create conflicts, it is recommended to use objects to create namespaces and isolate variables in named packages.

This is achieved as follows:

// Package declaration
var org = {}; // Package root
org.test = {}; // Package branch

// Package members
org.test.Dog = function(name, gender) {
  this.name = name;
  this.gender = gender;
};

org.test.Dog.prototype.bark = function() {
  return this.name + " barks!";
};

// Using the package
var myDog = new org.test.Dog("Rex", "Male");
print(myDog.bark());
Enter fullscreen mode Exit fullscreen mode
Rex barks!
Enter fullscreen mode Exit fullscreen mode

Prototypes

In our example, Dog is not a class but indeed an object, certainly a special one, but an object nonetheless. In fact, JavaScript doesn't implement a class-based programming model (which leads some to say that it's not an object-oriented language), but rather a prototype-based programming model, similar to that of the Self language.

For every function, JavaScript associates a particular object, called a prototype, to serve as a reference for all objects created using the new instruction. This object is accessible through the prototype property of the constructor.

When a value is assigned to a property, JavaScript creates the property at the object level if it doesn't already exist. On the other hand, when the value of a property is requested, JavaScript performs the following operations:

  • If the property exists at the object level, its value is returned; otherwise, JavaScript accesses the object's prototype and returns the prototype property's value if it exists;
  • Otherwise, JavaScript accesses the prototype's prototype, and so on;
  • If the property is not found by going up this prototype chain, JavaScript eventually arrives at the highest prototype, that of the generic Object, and then returns undefined.

Since an object's prototype is also an object, it can be dynamically modified. Modifying a prototype property (attribute or method) affects all instances due to the mechanism described above. This allows for dynamic and retroactive modification of all objects sharing the same prototype.

For example:

function Dog(name, gender) {
  this.name = name;
  this.gender = gender;
  this.bark = function() {
    return "Woof!";
  };
}

myDog = new Dog("Rex", "Male");

Dog.prototype.sleep = function() {
  return "Shh! " + this.name + " is sleeping...";
}

print(myDog.bark());
print(myDog.sleep());

myDog.sleep = function() {
  return "Zzz...";
};

print(myDog.sleep());
Enter fullscreen mode Exit fullscreen mode
Woof!
Shh! Rex is sleeping...
Zzz...
Enter fullscreen mode Exit fullscreen mode

Let's break down step by step the actions performed by the JavaScript interpreter to fully understand the relationship between an object and its prototype:

Prototype Chain

  • Step 1 - The interpreter detects a function:
    • An object (F here) representing the Dog function is created.
    • An object (P here) will serve as the prototype if the function is used as a constructor.
  • Step 2 - Assign to the variable myDog:
    • A blank object (O here) is created.
    • The Dog function is used as a constructor, with the implicit parameter this = O.
    • The object O now has the properties: name, gender, and bark.
    • The variable myDog is a reference to the object O.
  • Step 3 - Add a property to the constructor's prototype:
    • The bark property is added to object P.
  • Step 4 - Call the bark() method of the variable myDog:
    • myDog points to the object O.
    • The object O has a bark property, so it is returned (and since it's a function, it's executed and its value returned).
    • myDog.bark() returns "Woof!"
  • Step 5 - Call the sleep() method of the variable myDog:
    • myDog points to the object O.
    • The object O does not have the sleep property, so JavaScript goes to its prototype.
    • The prototype of object O is the prototype of its constructor, which is object P.
    • The object P has the sleep property, so it is returned (and since it's a function, it's executed and its value returned).
    • myDog.sleep() returns "Shh! Rex is sleeping..."
  • Step 6 - Rewrite the sleep() property of the variable myDog:
    • myDog points to object O.
    • The sleep property of the object O is created.
  • Step 7 - Call the sleep() method of the variable myDog:
    • myDog points to the object O.
    • The object O now has the sleep property, so JavaScript doesn't go to its prototype.
    • myDog.sleep() returns "Zzz..."

This dynamic modification mechanism through their prototype is also applicable to JavaScript's predefined objects:

String.prototype.reverse = function() {
  var reversed = "";
  for (i = this.length; --i >= 0;) {
    reversed += this.charAt(i);
  }
  return reversed;
} 

var myString = "Hello!";

print(myString.reverse());
Enter fullscreen mode Exit fullscreen mode
!olleH
Enter fullscreen mode Exit fullscreen mode

The reverse() method is now available for all strings!

Let's translate the content into English:

Static Properties

Properties declared outside of the constructor (and therefore not present in the prototype) are not referenced by instantiated objects. This allows for the implementation of the equivalent of static methods and attributes in class-based programming (sometimes referred to as class methods and attributes):

var Dog = function(name, gender) {
  this.name = name;
  this.gender = gender;
  this.bark = function() {
    return "Woof!";
  }
}

//Static method for detecting identical names
Dog.hasSameName = function(dog1, dog2) {
  return (dog1.name == dog2.name);
}

var myDog = new Dog("Rex", "Male");
var myBitch = new Dog("Mirza", "Female");

print(Dog.hasSameName(myDog, myBitch));

myBitch.name = "Rex";
print(Dog.hasSameName(myDog, myBitch));
Enter fullscreen mode Exit fullscreen mode
false
true
Enter fullscreen mode Exit fullscreen mode

Inheritance

Prototype-based Inheritance

As we have already pointed out, the prototype-based programming model does not use the concepts of classes and class instances, but only that of objects. Therefore, inheritance doesn't occur between classes defined statically, but directly between object prototypes.

Inheritance between two objects is achieved by assigning the parent object to the prototype of the child. It is then possible to add new properties to the child or even replace those inherited from the parent.

Let's now imagine that I also want to represent my cat Felix, while reusing what we did for Rex and Mirza.

Inheritance

Let's create a parent object Animal and two child objects, Dog and Cat:

// Parent object
function Animal(name, gender) {
  this.name = name;
  this.gender = gender;
  this.eat = function() {
    return this.name + " eats";
  }
  this.sleep = function() {
    return "Quiet! " + this.name + " is sleeping...";
  }
}

// First child object
function Dog(name, gender) {
  // Passing parameters to the parent object
  Animal.call(this, name, gender);
} // which inherits from Animal

Dog.prototype = new Animal();
// but is of type Dog
Dog.prototype.constructor = Dog;

// Add a method to the child
Dog.prototype.bark = function() {
  return this.name + " barks!";
}

// Second child object
function Cat(name, gender) {
  // Passing parameters to the parent object
  Animal.call(this, name, gender);
} // which inherits from Animal

Cat.prototype = new Animal();
// but is of type Cat
Cat.prototype.constructor = Cat;

// Add a method to the child
Cat.prototype.meow = function() {
  return this.name + " meows!";
}

var myDog = new Dog("Buddy", "Male");
var myCat = new Cat("Whiskers", "Male");

// Add a method to the parent object, after creating child instances
Animal.prototype.eat = function() {
  return this.name + " eats";
}

print(myDog.sleep());
print(myDog.bark());
print(myDog.eat());

print(myCat.sleep());
print(myCat.meow());
print(myCat.eat());
Enter fullscreen mode Exit fullscreen mode
Quiet! Buddy is sleeping...
Buddy barks!
Buddy eats
Quiet! Whiskers is sleeping...
Whiskers meows!
Whiskers eats
Enter fullscreen mode Exit fullscreen mode

Note the use of the call() method of the JavaScript Function object, which allows a function to be called as if it belonged to the object to which it is applied. We use this function to pass the name and gender parameters between the constructors of the children and the parent.

Multiple Inheritance

Although JavaScript does not support multiple inheritance, it is possible to partly simulate this behavior by using the call() method mentioned above. However, since an object can only have one constructor (and therefore one prototype) at a time, it is impossible to maintain the dynamic link between parent and child objects.

Let's try to represent a Werewolf, which is both a Man and a Wolf, through prototype inheritance:

Multiple inheritance

// First parent object
function Wolf() {
  this.bite = function() {
    return this.name + " has bitten you!";
  };
}

// Second parent object
function Man() {
  this.speak = function() {
    return this.name + " speaks to you...";
  };
}

// Child object
function Werewolf(name) {
  this.name = name;
  Wolf.call(this); // Call the constructor of the first parent
  Man.call(this); // Call the constructor of the second parent
  this.transform = function() {
    return this.name + " has transformed!";
  };
}

var myWerewolf = new Werewolf("Jack");

print(myWerewolf.speak());
print(myWerewolf.transform());
print(myWerewolf.bite());
Enter fullscreen mode Exit fullscreen mode
Jack speaks to you...
Jack has transformed!
Jack has bitten you!
Enter fullscreen mode Exit fullscreen mode

Dynamic Inheritance

When creating a new object using the new operator, the created object is assigned an implicit reference to the constructor's prototype. Since this prototype is also an object, it seems feasible to dynamically modify an object's inheritance by modifying its prototype chain.

Let's now try to represent a Werewolf, which is either a Man or a Wolf but never both at the same time:

Dynamic inheritance

// First parent object
function Wolf() {
}

Wolf.prototype.speak = function() {
  return this.name + " growls...";
};

Wolf.prototype.bite = function() {
  return this.name + " has bitten you!";
};

// Second parent object
function Man() {
}

Man.prototype.speak = function() {
  return this.name + " speaks to you...";
};

Man.prototype.bite = function() {
  return this.name + " bit his tongue!";
};

// Child object
function Werewolf(name) {
  this.name = name;
}

Werewolf.prototype = new Man();

var myWerewolf = new Werewolf("Jack");
print(myWerewolf.speak());

Werewolf.prototype = new Wolf();
print(myWerewolf.speak());

var myWerewolf2 = new Werewolf("Joe");
print(myWerewolf2.speak());
Enter fullscreen mode Exit fullscreen mode

Regrettably, the previous code returns this:

Jack speaks to you...
Jack speaks to you...
Joe growls...
Enter fullscreen mode Exit fullscreen mode

The myWerewolf instance wasn't impacted by the modification of its constructor's prototype. This can be explained by the fact that its implicit prototype property still points to the same object, even if it's not the new prototype of its constructor. On the other hand, the new myWerewolf2 instance does indeed have an implicit reference to the new prototype.

To dynamically modify an object's inheritance, we would need to be able to directly modify the implicit reference to the constructor's prototype. The implementation of this property, although discussed in the ECMA-262 standard, is not mandatory. Thus, it's not available in all ECMAScript implementations.
It is generally implemented by the __proto__ property. The following code proves that the __proto__ property indeed points to the object's constructor's prototype:

// Parent object
function Wolf() {
}

Wolf.prototype.speak = function() {
  return this.name + " growls...";
};

Wolf.prototype.bite = function() {
  return this.name + " has bitten you!";
};

// Child object
function Werewolf(name) {
  this.name = name;
}

Werewolf.prototype = new Wolf();

var myWerewolf = new Werewolf("Jack");
print((myWerewolf.__proto__ === Werewolf.prototype));
Enter fullscreen mode Exit fullscreen mode
true
Enter fullscreen mode Exit fullscreen mode

Thus, we can now dynamically reassign the Werewolf's parent:

// First parent object
function Wolf() {
}

Wolf.prototype.speak = function() {
  return this.name + " growls...";
};

Wolf.prototype.bite = function() {
  return this.name + " has bitten you!";
};

// Second parent object
function Man() {
}

Man.prototype.speak = function() {
  return this.name + " speaks to you...";
};

Man.prototype.bite = function() {
  return this.name + " bit his tongue!";
};

// Child object
function Werewolf(name) {
  this.name = name;
}

Werewolf.prototype = new Man();

// Reassignment of the Werewolf's prototype
Werewolf.prototype.transform = function() {
  switch (this.__proto__.constructor.name) {
    case "Man":
      Wolf.call(this);
      this.__proto__.__proto__ = new Wolf();
      return this.name + " has transformed into a Wolf!";
      break;
    case "Wolf":
      Man.call(this);
      this.__proto__.__proto__ = new Man();
      return this.name + " has transformed into a Man!";
      break;
  }
}

var myWerewolf = new Werewolf("Jack");

print(myWerewolf.speak());
print(myWerewolf.bite());
print(myWerewolf.transform());

print(myWerewolf.speak());
print(myWerewolf.bite());
print(myWerewolf.transform());

print(myWerewolf.speak());
Enter fullscreen mode Exit fullscreen mode
Jack speaks to you...
Jack bit his tongue!
Jack has transformed into a Wolf!
Jack growls...
Jack has bitten you!
Jack has transformed into a Man!
Jack speaks to you...
Enter fullscreen mode Exit fullscreen mode

This dynamic inheritance example demonstrates the power of JavaScript.

Closures

Closures are a very powerful feature of JavaScript, yet they remain relatively unknown or often misunderstood, leading to the inadvertent production of faulty code. In simple terms, it's the ability of an inner function to access properties of the enclosing function, even after the enclosing function has finished its execution.

This is made possible through two mechanisms implemented by JavaScript: execution contexts and scopes. With every function call, JavaScript creates a new execution context and associates it with a scope, consisting of a series of objects that determine how the function's variables are initialized. This is applicable to functions nested within other functions, which includes recursive functions. These objects carry the different contexts that led to the function's execution. The lowest execution context is that of the JavaScript script itself and is carried by the Global object.

Example №1

The concept of closures isn't easy to grasp in a theoretical sense, so let's illustrate it with a concrete example:

01: var number1 = 7;
02: var number2 = -2;
03:
04: function Outer(num) {
05:   var factor = 3;
06:   this.Inner = function (coef) {
07:    return (num * factor * coef);
08:   };
09: }
10:
11: var MyObject1 = new Outer(number1);
12: print(MyObject1.Inner(10));
13:
14: var MyObject2 = new Outer(number2);
15: print(MyObject2.Inner(100));
Enter fullscreen mode Exit fullscreen mode
210
-600
Enter fullscreen mode Exit fullscreen mode

Closure

Let's delve into the details of what JavaScript does when it parses the above code:

  • global context:

    • lines 1 and 2: at the most basic execution context, JavaScript adds properties number1 and number2 with values 7 and -2 to the Global object, which holds the script's execution context.
  • Step 1:

    • line 11: JavaScript creates a new object MyObject1 by executing the Outer function as a constructor:
    • line 4: JavaScript establishes a new execution context for the Outer function and an object (called "Outer Call Scope" in the diagram) carrying the num parameter and function properties (including factor). The scope of this execution context consists of the "Outer Call Scope" object linked with Global.
    • line 6: JavaScript creates a new execution context for the Inner function and an object (called "Inner Call Scope" in the diagram) carrying the coef parameter. The scope of this execution context consists of the "Inner Call Scope" object linked with "Outer Call Scope" and then Global.
    • line 8: execution of the Inner function concludes, but its scope is retained in memory as it's referenced by another execution context (usage of return).
    • line 9: execution of the Outer function ends, its scope isn't referenced anymore and will be removed in the next garbage collector cycle. JavaScript returns to the global execution context.
    • line 12: the Inner method is called, JavaScript uses the retained context and scope to initialize variables and execute the function:
    • num equals 7;
    • factor equals 3;
    • coef equals 10;
    • Inner() returns 210.
  • Step 2:

    • line 14: the previous process is performed again, leading to the creation of a new execution context and a new scope, different from those in Step 1.
    • line 15, in this new scope:
    • num equals -2;
    • factor equals 3;
    • coef equals 100;
    • Inner() returns -600 this time.

Example №2

Here's a second example of a closure used to implement a function of functions:

function CreateDogAction(action) {
  return function() {
    return this.name + " is " + action;
  }
}

function Dog(name) {
  this.name = name;
  this.eat = CreateDogAction("eating");
  this.sleep = CreateDogAction("sleeping");
  this.bite = CreateDogAction("biting");
}

var myDog = new Dog("Rex");
print(myDog.eat());
print(myDog.sleep());
print(myDog.bite());
Enter fullscreen mode Exit fullscreen mode
Rex is eating
Rex is sleeping
Rex is biting
Enter fullscreen mode Exit fullscreen mode

Example #3

A closure can contain multiple functions accessing a common property, as illustrated by this counter management example:

function initCounter(start) {
  // Private variable
  var counter = start;

  // Create the counter manipulation functions
  getCounter = function() {
    return counter;
  };

  incCounter = function() {
    counter++;
  };

  setCounter = function(value) {
    counter = value;
  };

  resetCounter = function() {
    counter = start;
  };
}

initCounter(0);
setCounter(10);
incCounter();
print(getCounter());

resetCounter();
print(getCounter());
Enter fullscreen mode Exit fullscreen mode
11
0
Enter fullscreen mode Exit fullscreen mode

Conclusion

This article was written to shed light on some of JavaScript's features that often go unnoticed when the language is simply associated with dynamic web page features. However, delving deeper reveals that JavaScript is indeed a potent, ever-evolving language, underpinning the success of modern web applications.

The lack of knowledge regarding JavaScript's object features is likely tied to unfamiliarity with the prototype-based programming model. This is probably why ECMAScript version 6 also introduces the class-based programming model to the language.

Was this truly a wise move? I'll leave that for you to decide. In my view, it certainly hasn't encouraged the widespread use of prototypes, which are, after all, at the very heart and strength of JavaScript...

Top comments (1)

Collapse
 
jjn1056 profile image
John Napiorkowski

Vy good overview! Getting back into JS after being away a long time and everything you present here jives with what I remember from the early language specs.