loading...

Customize objects coercion in JavaScript

jfet97 profile image Andrea Simone Costa 惻6 min read

Introduction

JavaScript, unlike other programming languages, doesn't allow operators overloading. What it allows us to do is to modify the primitive value in which an object is transformed. This is because when an object is coerced, the result is, and must be, a primitive value.

Unfortunately we can consider this opportunity only a paltry consolation prize, because the control we can obtain is quite limited. In any case it could always be useful to know what possibilities the language offers to us, considering that ES6 has brought a solution of considerable value.

The whole article is based on a good number of concepts related to coercion. In fact, what we can do is modify the result of the coercion performed on our objects.

Customization before ES6

Since coercion of objects is strongly based on toString and valueOf methods, which are callable by default on any object, what we could do before ES6 was simply redefine those methods on our objects.

Why did I use the word redefine? Why are the two methods callable by default? In Javascript all objects are automatically linked to the Object.prototype object thanks to what is called the prototype chain.
This object defines a fair number of methods which are therefore invocable directly from the all the objects connected to it. Learn more here.

Before to redefine their behaviour, let's see the default one:

    var object = {
        prop: "value",
    };

    object.valueOf(); // object
    object.toString(); // "[object Object]"

As you can see, valueOf simply returns the object itself (a reference to). Instead the other method, toString, reads the value of the [[Class]] internal property and constructs the well know "[object Object]" string.

Also arrays are objects, but for them the behaviour of the toString method was already redefined by default:

    var array = [1, 2, 3];

    array.valueOf(); // array
    array.toString(); // "1,2,3"

When any object is coerced, depending on the initial conditions, a method between toString and valueOf will take precedence. Only if it does not return a primitive value, such as the valueOf method of Object.prototype, the other method will be invoked as fallback. If even it doesn't return a primitive value, a TypeError will be raised.

Warning! We could decide to return null or undefined when we redefine these methods because they are valid primitive values. However, Javascript never leads to this result for the objects it makes available to us and we should follow its example, returning one of string, number and boolean.

Numerical operations like Number(obj), +obj (unary +), unary -, binary -, *, **, /, % will clearly prioritize the valueOf method, while an explicit coercion operation like String(obj) will give priority to the toString method.
Doubts arise when facing the binary + and the == loose equality operator, which give priority to valueOf.

Let's see, in practice, how this stuff can help us to reach our goal:

    var myObj = {
        toString: function() {
            return "myObj";
        },
        valueOf: function() {
            return 10;
        }
    }

    // valueOf precedence
    Number(myObj); // 10
    +myObj; // 10
    myObj - 7; // 3
    myObj % 3; // 1
    myObj + 15; // 25
    "this is " + myObj; // "this is 10"
    myObj == 10; // true

    // toString precedence
    String(myObj); // "myObj"

We have therefore redefined the behaviour of our object.

We may however not be satisfied with the result of "this is" + myObj;, preferring "this is myObj" to it. Unfortunately, apart from explicit coercion using the String function, the only way to do this, in ES5, is to impose the return of the string "myObj" to the valueOf method as well, sacrificing the possibility of using the object in arithmetic operations, penalty an infinite series of NaN deriving from the coercion attempt of the "myObj" string in a number.

Customization with ES6

Let me introduce a new concept: the hint. When any object is coerced into a primitive, the decision to give priority to one of the two method of which we spoke earlier is done thanks to an hint.
Possible values for the hint are: number, string, default.
When the hint value is string will be given precedence to the toString method, when it is number or default is the valueOf method which has priority. The only exception to this logic is the Date "type", for which the default hint value will give priority to the toString method.

Let's see what hints are forwarded by the main operations seen so far:

Hint Operations
string String(), interpolation [ES6]
number Number(), unary +, unary and binary -, , *, /, %
default binary +, ==

It may seem the opposite, but ES5 is also based on the same concepts. The difference lies in the possibility, offered by ES6, to interact directly with this mechanism.

It is necessary to provide to the object a method with a special name, which takes a string with the value of the hint. Based on it we can decide what to do, like manually call the valueOf and toString methods if it is our wish, or invoke other methods. The important thing is to produce a primitive value as the end result, to avoid running into a TypeError.

What is the special name of this method? It's Symbol.toPrimitive. This article will not talk about symbols, because it is not necessary to have confidence with them to reach our goal.

Let's see a possible implementation of it, which allows us to obtain the same behavior defined by ES5:

    let obj = {
        [Symbol.toPrimitive](hint) {
            // it checks if a value is an object
            const isObject = (value) => value !== null 
                                        && typeof value === 'object' 
                                        || typeof value === 'function';

            switch(hint) {
                case "default": 
                case "number": 
                    // precedence to valueOf if the hint is "number" or "default"
                    const value = this.valueOf();

                    // if the result is a primitive, it can be returned
                    if(!isObject(value)) return value;

                    // otherwise the toString method is used as fallback
                    else return this.toString();

                case "string": 
                    // precedence to toString if the hint is "string"
                    const string = this.toString();

                    // if the result is a primitive, it can be returned
                    if(!isObject(string)) return string;

                    // otherwise the valueOf method is used as fallback
                    else return this.valueOf() 
             }
        }
    }

What could we do if toString and valueOf methods are not trustable and could lead to a TypeError?
Let's see a possible workaround:

    let obj = {
        [Symbol.toPrimitive](hint) {
            // it checks if a value is an object
            const isObject = (value) => value !== null 
                                        && typeof value === 'object' 
                                        || typeof value === 'function';

            switch(hint) {
                case "default": 
                case "number": 
                    // precedence to valueOf if the hint is "number" or "default"
                    let res = this.valueOf();

                    // if the result is a primitive, it can be returned
                    if(!isObject(res)) return res;

                    // otherwise the toString method is used as fallback
                    res = this.toString();

                    // if the result is a primitive, it can be returned
                    if(!isObject(res)) return res;

                    // otherwise returns an appropriate primitive value
                    return NaN;


                case "string": 
                    // precedence to toString if the hint is "string"
                    let res = this.toString();

                    // if the result is a primitive, it can be returned
                    if(!isObject(string)) return string;

                    // otherwise the valueOf method is used as fallback
                    res =  this.valueOf();

                    // if the result is a primitive, it can be returned
                    if(!isObject(res)) return res;

                    // otherwise returns an appropriate primitive value
                    return "";
             }
        }
    }

We can easily add more substantial changes to the mechanism, bearing in mind the table shown above. As a practical example, I take the previously defined myObj object, where I wanted the object to be transformed into a string value instead of a numeric one when the binary + operator comes to action. As the table illustrates, the == operator will also be affected by my change.

    let myObj = {
        toString() {
            return "myObj";
        },
        valueOf() {
            return 10;
        },
        [Symbol.toPrimitive](hint) {
            switch(hint) {
                case "number": 
                    return this.valueOf();

                case "default":     
                case "string": 
                    return this.toString();
             }
        }
    }

It is worth noting that we could still use the numeric value of myObj in a binary + operation or with the == operator if necessary, thanks to the unary +.
The table confirms this statement: unary + suggests "number".

    "this is " + myObj; // "this is myObj"
    `greetings from ${myObj}`; // "greetings from myObj"
    "myObj" == myObj; // true

    32 + +myObj; // 42
    `greetings from ${+myObj}`; // "greetings from 10"
    10 == +myObj; // true

Posted on by:

jfet97 profile

Andrea Simone Costa

@jfet97

I write JavaScript code, mostly.

Discussion

markdown guide