Deep Dive Beyond Operator Overloading in JavaScript

Nabil Tharwat - Apr 2 '21 - - Dev Community

This is a deep dive into the inner workings of JavaScript engines and how they handle arithmetic operations on primitive and complex objects. We'll go through ordinary and exotic objects, the standard semantics, references, abstract operations, internal methods, and finally how to implement objects that benefit from arithmetic operators.

If you haven't read my previous articles make sure to!

AdditiveExpressions

Before we start, let me note that JavaScript doesn't support operator overloading in the general sense like C++ does for example, but it provides deep workings that allow us to define special methods that are used in arithmetic operations, like Java's toString!

5 + 8;
Enter fullscreen mode Exit fullscreen mode

Let's start with this simple arithmetic addition AdditiveExpression : AdditiveExpression + MultiplicativeExpression. The standard defines the steps for an addition operation:

1.  Let lref be the result of evaluating AdditiveExpression.
2.  Let lval be ? GetValue(lref).
3.  Let rref be the result of evaluating MultiplicativeExpression.
4.  Let rval be ? GetValue(rref).
5.  Let lprim be ? ToPrimitive(lval).
6.  Let rprim be ? ToPrimitive(rval).
7.  If Type(lprim) is String or Type(rprim) is String, then
  a.  Let lstr be ? ToString(lprim).
  b.  Let rstr be ? ToString(rprim).
  c.  Return the string-concatenation of lstr and rstr.
8.  Let lnum be ? ToNumeric(lprim).
9.  Let rnum be ? ToNumeric(rprim).
10.  If Type(lnum) is different from Type(rnum), throw a TypeError exception.
11.  Let T be Type(lnum).
12.  Return T::add(lnum, rnum).
Enter fullscreen mode Exit fullscreen mode

Pretty daunting right? Let's dumb it down!

Semantics

The standard defines any additive operation as the result of two operands, l and r, being left, and right respectively. It also attaches other semantic descriptors like ref, val, prim, str, and num to refer to Reference, Value, Primitive, String, and Numeric values respectively.

JavaScript Engine References

The standard operates using References. References are special objects/variables that reference other variables in memory. This is to save resources so instead of copying a variable every time the engine needs it, it can just reference it, which is more memory and performance efficient. This Reference type can be dereferenced to get the actual value by using the GetValue(V) method.

The GetValue(V) method itself has an algorithm of its own. I've dumbed it down without going too deep as follows:

1. If V is not a reference, return it.
2. If V is invalid reference (as in using a variable that doesn't exist), throw ReferenceError.
3. Else return value.
Enter fullscreen mode Exit fullscreen mode

Exotic and Ordinary Objects

In JavaScript, an exotic object is an object that contains behaviour that goes above and beyond the language itself. These objects require internal methods that are enclosed in double square brackets [[ ]]. Think Array, Proxy, Arguments, and Module for example. The JavaScript engine does a lot of magic using internal methods to work with those objects. You cannot completely replicate this magic using just JavaScript.

Ordinary objects are normal objects that you can build using JavaScript code.

Primitives and Abstract Operations

Primitives in JavaScript are the most basic values that can be represented in the engine directly. This includes booleans, strings, numbers, and others. The standard defines primitive helpers called Abstract Operations. These helper functions allow the engine to directly manipulate values such as add two numbers, subtract, and others. Each primitive type has its own set of helpers.

Now that we have a basic understanding of how things in the EcmaScript world work let's dive into addition.

1.  Let lref be the result of evaluating AdditiveExpression.
2.  Let lval be ? GetValue(lref).
3.  Let rref be the result of evaluating MultiplicativeExpression.
4.  Let rval be ? GetValue(rref).
Enter fullscreen mode Exit fullscreen mode

Up until the forth step all we do is just dereference the references we have. Now we have two values, lval and rval.

5.  Let lprim be ? ToPrimitive(lval).
6.  Let rprim be ? ToPrimitive(rval).
Enter fullscreen mode Exit fullscreen mode

We now turn these values into primitives so we can operate on them easily on the engine level. The abstract operation ToPrimitive converts its input argument to a non-Object type. It has a somewhat long algorithm.

ToPrimitive and @@toPrimitive

ToPrimitive takes two parameters, the value you wish to turn into a primitive, and a Hint PreferredType. This Hint helps ToPrimitive determine the Target type.

When ToPrimitive is called with no hint, then it generally behaves as if the hint were Number. However, objects may over-ride this behaviour by defining a @@toPrimitive method. Of the objects defined in this specification only Date objects (see 20.4.4.45) and Symbol objects (see19.4.3.5) over-ride the default ToPrimitive behaviour. Date objects treat no hint as if the hint were String.

Meaning that if Hint is not present the function falls back to "number" for all objects except Date, which defines Hint as "string". This is one of the reasons Date is an exotic object. Date also defines more internal methods to help with serializing it to JSON.

Ignoring unimportant steps, the ToPrimitive algorithm is:

2. If Type(input) is Object, then
  d.  Let exoticToPrim be ? GetMethod(input, @@toPrimitive).
  e.  If exoticToPrim is not undefined, then
    i.  Let result be ? Call(exoticToPrim, input, « hint »).
    ii.  If Type(result) is not Object, return result.
    iii. Else throw a TypeError exception.
  f.  If hint is "default", set hint to "number".
  g.  Return ? OrdinaryToPrimitive(input, hint).
3.  Return input
Enter fullscreen mode Exit fullscreen mode

The key here is @@toPrimitive at 2.d. Remember what we said about Ordinary and Exotic objects? @@ToPrimitive is an internal method defined only on some exotic objects that control how the object is turned into a primitive. If this method is defined on the object we're working with (Date and Symbol), it'll be called and the result will be returned. Otherwise we'll resort to OrdinaryToPrimitive.

OrdinaryToPrimitive

OrdinaryToPrimtive bears the responsibility of turning ordinary objects into primitives. We're getting close now! It does the following:

3.  If hint is "string", then
  a.  Let methodNames be « "toString", "valueOf" ».
4.  Else,
  a.  Let methodNames be « "valueOf", "toString" ».
5.  For each name in methodNames in List order, do
  a.  Let method be ? Get(O, name).
  b.  If IsCallable(method) is true, then
    i.  Let result be ? Call(method, O).
    ii.  If Type(result) is not Object, return result.
6.  Throw a TypeError exception.
Enter fullscreen mode Exit fullscreen mode

We define a list of method names to call in order. This list can either be [toString, valueOf] if the hint is "string" (as in string concatenation), or [valueOf, toString] if hint is "number" (as in number addition). We then execute this list and return the value of whichever method we find first, in the same order.

We've now called ToPrimitive on both operands and have two primitives to add together. The algorithm for AdditiveExpression continues:

7.  If Type(lprim) is String or Type(rprim) is String, then 
  a.  Let lstr be ? ToString(lprim).
  b.  Let rstr be ? ToString(rprim).
  c.  Return the string-concatenation of lstr and rstr.
8.  Let lnum be ? ToNumeric(lprim).
9.  Let rnum be ? ToNumeric(rprim).
10.  If Type(lnum) is different from Type(rnum), throw a TypeError exception.
11.  Let T be Type(lnum).
12.  Return T::add(lnum, rnum)
Enter fullscreen mode Exit fullscreen mode

We see that if any of the primitives is a string then we convert both of them to strings and concatenate them. Otherwise we convert them to numbers and use the Abstract Operations defined on Number primitives, specifically add(lnum, rnum).

Now we have a pretty good understanding of how addition works! But we're talking about operator overloading! Remember what I said about OrdinaryToPrimitive? OrdinaryToPrimitive looks for toString and valueOf on objects depending on the operation. Which means we can just define them on our custom objects and use arithmetic operators with them!

Operator Overloading Custom Objects

Let's start by defining an object A that doesn't implement these methods:

const A = {
    value: 5,
}
Enter fullscreen mode Exit fullscreen mode

If we try to do arithmetic operations on this object we'll get strings all the time.

A + 5 // [object Object]5
A - 5 // NaN
A * 5 // NaN
A / 5 // NaN
Enter fullscreen mode Exit fullscreen mode

Now let's implement these methods on this object:

const A = {
    value: 5,
    valueOf: function(){
        return this.value;
    },
    toString: function(){
        return String(this.value)
    }
}
Enter fullscreen mode Exit fullscreen mode

Arithmetic operations should now work flawlessly!

A + 5 // 10
A - 5 // 0
A * 5 // 25
A / 5 // 1
Enter fullscreen mode Exit fullscreen mode

So now we can not only just define some methods to use operator overload on our objects, but we also deeply understand how JavaScript engines do it!

If you liked this article don't forget to love this post! If you found any issues with this article or have questions don't hesitate to comment them! Thanks for reading! You can follow me on Twitter, or read more of my content here or on my personal blog!

. . . . . . . . . . .