JS Tip of the Day: String Conversions in Depth

String Conversions in Depth
Version: ES2015 (Symbol, Symbol.toPrimitive)
Level: Advanced

Converting values to strings is such a trivial thing, it’s easy to overlook the complexities of it. That is, maybe, at least until it becomes a problem, like when you start seeing [object Object] in unexpected places. We’ve seen how that can be customized with Symbol.toStringTag, but that’s only part of the string conversion story.

When it comes to converting values in JavaScript to strings it’s all about first reducing the value into a primitive value if it isn’t one already. Once you have a primitive value, the conversion from that value to a string follows a (mostly) simple set of rules:

  1. If the primitive is already a string, return the string
  2. If the primitive is undefined return “undefined”
  3. If the primitive is null return “null”
  4. If the primitive is true return “true”
  5. If the primitive is false return “false”
  6. If the primitive is a number (Number or BigInt) return the string version of the number*
  7. If the primitive is a symbol throw an error

* The process for string conversion from numbers is a little complex, dealing with special values like Infinity and NaN and different ways to present very large or small numbers, so I won’t get into the details here, but for the most part it’s not doing anything too terribly unexpected.

Symbols are a bit of an anomaly here. You may have noticed that they will throw an error when being converted to a string. Symbols can, in fact, be converted to strings, and normally that step would have looked something like:

  1. If the primitive is a symbol return Symbol(${primitive.description ?? ''})

But the actual error-throwing behavior was added to prevent people from accidentally converting a symbol key in to a string key without realizing it. To get the string conversion behavior above, you must explicitly convert the symbol to a string by passing it into String() or calling its toString() method.

let privateKey = Symbol('private');
let badMessage = `The key is ${privateKey}.`;
// TypeError: cannot convert (implicit)

let workingMessage = `The key is ${String(privateKey)}.`;
// (Ok, explicit)
console.log(workingMessage); // The key is Symbol(private).

Overall, String() is generally preferred over the toString() method since it can work on values that do not have toString() methods such as undefined and null. String() also makes a special exception for symbols, converting them to strings rather than going through the steps that would normally throw an error.

When it comes to objects being converted to strings, it would be easy to assume that it would always be handled through an object’s toString() method. But in reality, that’s not always the case. Since objects are converted to primitives and not directly to strings when they’re converted into strings, it’s possible another method might be used. There are, in fact, 3 possible approaches for converting objects into primitives. They include:

  • toString()
  • valueOf()
  • Symbol.toPrimitive

What approach is used depends on availability and why or how the object is getting converted to a primitive in the first place. Causes for an object to be converted into a primitive may include:

  • Primitive conversions/factories (BigInt, Number, String, Symbol)
  • Conversion to object property key
  • Arithmetic or bitwise operations
  • String concatenation
  • Loose equality comparisons

Before getting into those, let’s take a look at the different to primitive approaches, starting first with toString(). This is the method that, given the default implementation provided by the Object type, produces the infamous “[object Object]” string. Other object types may instead provide alternate implementations of toString(). For example arrays will output something similar to calling join(',') while RegExp objects will produce something matching their literal form. Most objects, however, will be using the default Object implementation.

let ordinaryObj = {};
console.log(ordinaryObj.toString()); // [object Object]

let numbersArr = [1,2,3];
console.log(numbersArr.toString()); // 1,2,3

let regex = /abc/gi;
console.log(regex.toString()); // /abc/gi

valueOf(), on the other hand, is used to get the “value” of the current object. This usually means the object itself, as the default implementation by Object returns. The primitive object types have their own implementation, though, using valueOf() as a way to allow you to access to their primitive values. For example an object version of a number would return its primitive number value when calling valueOf() rather than the number object itself.

let ordinaryObj = {};
console.log(ordinaryObj.valueOf() === ordinaryObj); // true

let numberObj = new Number(3);
console.log(numberObj.valueOf() === numberObj); // false
console.log(numberObj.valueOf() === 3); // true

Symbol.toPrimitive is a little different. Object does not implement this, nor do most other built-in types with the exceptions of Symbol and Date. Instead Symbol.toPrimitive represents an override to both toString() and valueOf() to be used as an alternative to those methods when an object needs to be converted to a primitive. To understand how this is used, we need to get back to the reasons for a conversion.

Ignoring Symbol.toPrimitive for a moment, the standard approach taken to convert an object to a primitive would be to use either toString() or valueOf(). If only one exists, that one will be used. If they both exist, one is preferred, and if when called, it doesn’t return a primitive, the other is used. If neither exist, or neither of those available return a primitive, an error is thrown. Which one is called first depends on the operation converting the object to a primitive.

  • Prefers toString():
    • Primitive conversions/factories (String, Symbol)
    • Conversion to object property key
  • Prefers valueOf():
    • Primitive conversions/factories (BigInt, Number)
    • Arithmetic or bitwise operations
    • String concatenation
    • Loose equality comparisons

There are actually 3 kinds of preferences: “string” (toString()), “number” (valueOf()), and “default”. These are known as the “hint” in the conversion process. But by default, the “default” hint will use valueOf().

Of the conversions listed above under valueOf(), both string concatenation and loose equality comparisons have a hint of “default” (yes, you heard right, for whatever reason string concatenation uses “default”, not “string”). While this distinction isn’t made by valueOf() it is seen in Symbol.toPrimitive as the hint gets passed in as an argument.

Taking these preferences into account, and including the Symbol.toPrimitive override, we can map out the rules for object to primitive conversions:

  1. If the object has a Symbol.toPrimitive method, call it, passing in hint, capturing its return value as result
    1. If result is a primitive return result
    2. Else throw an error
  2. If the operation has a “string” hint, set primary to toString() and set secondary to valueOf()
  3. Else set primary to valueOf() and set secondary to toString()
  4. If the object has a primary method, call it capturing its return value as result
    1. If result is a primitive return result
  5. If the object has a secondary method, call it capturing its return value as result
    1. If result is a primitive return result
  6. If no primitive has been returned by this point, throw an error

One thing to notice is that a Symbol.toPrimitive method overrides the other methods completely. If it exists at all, the others are ignored and never called. If it fails to return a primitive, an error is thrown. Since in most cases this method won’t exist, it will normally come down to toString() and valueOf().

Of course because most object implementations of valueOf() use the default from Object which returns the object itself, chances are, even if valueOf() is preferred and called first, the non-primitive object value it returns is going to cause it to get ignored. So in the end, when objects are converted to primitives, toString() ends up being the most likely method used.

We can see this conversion at work by creating a spy object with each method implemented and running it through some conversions to see what approaches are taken.

let spy = {
    toString () {
        console.log('toString()');
        return 'spy';
    },
    valueOf () {
        console.log('valueOf()');
        return 'spy';
    },
    [Symbol.toPrimitive] (hint) {
        console.log(`toPrimitive(${hint})`);
        return 'spy';
    }
};

You may have already realized that simply because Symbol.toPrimitive exists, it will be used for all of the primitive conversions and toString() and valueOf() will be ignored. By capturing the hint passed into it when called, we can also see the preference for each kind of conversion.

String(spy);
// toPrimitive(string)

let object = {};
object[spy] = 'as a key';
// toPrimitive(string)

Number(spy);
// toPrimitive(number)

spy * 3;
// toPrimitive(number)

spy + 'concat';
// toPrimitive(default)

spy == 'compared';
// toPrimitive(default)

Next we can remove Symbol.toPrimitive to see toString() and valueOf() getting called. These should match up with the preferences seen in the previous calls to the Symbol.toPrimitive method, where “string” hints call toString(), and “number” and “default” call valueOf().

let spy = {
    toString () {
        console.log('toString()');
        return 'spy';
    },
    valueOf () {
        console.log('valueOf()');
        return 'spy';
    }
};

String(spy);
// toString()

let object = {};
object[spy] = 'as a key';
// toString()

Number(spy);
// valueOf()

spy * 3;
// valueOf()

spy + 'concat';
// valueOf()

spy == 'compared';
// valueOf()

Finally we can prevent either from returning a primitive to see how each toString() and valueOf() is used as a fallback for the other. However, since neither return a primitive, each conversion will also fail catastrophically with a TypeError.

let spy = {
    toString () {
        console.log('toString()');
        return this;
    },
    valueOf () {
        console.log('valueOf()');
        return this;
    }
};

String(spy);
/* logs:
toString()
valueOf()
(then TypeError!)
*/

let object = {};
object[spy] = 'as a key';
/* logs:
toString()
valueOf()
(then TypeError!)
*/

Number(spy);
/* logs:
valueOf()
toString()
(then TypeError!)
*/

spy * 3;
/* logs:
valueOf()
toString()
(then TypeError!)
*/

spy + 'concat';
/* logs:
valueOf()
toString()
(then TypeError!)
*/

spy == 'compared';
/* logs:
valueOf()
toString()
(then TypeError!)
*/

While most objects will be using toString(), or maybe even valueOf() in these conversions, if you’ll remember, there were two builtins that implemented the Symbol.toPrimitive method. These were Symbol and Date. The reasons for them doing so are different for each.

One of the things that makes symbols unique is that they can be used as property keys in objects just like strings. However, when it comes to converting objects to property keys, you may have noticed that toString() is preferred over valueOf(). This could cause problems for symbol objects since their symbol values should be used as keys, not their string representations. To fix this, Symbol implements its own Symbol.toPrimitive method to return a symbol object’s symbol value rather than its string value for any primitive conversion.

let symbolPrim = Symbol('key');
let symbolObj = Object(symbolPrim);

let rightKey = {};
rightKey[symbolObj] = 'as a key';
console.log(rightKey[symbolPrim]); // as a key

// show would-be default behavior without override
Object.defineProperty(symbolObj, Symbol.toPrimitive, { value: null });

let wrongKey = {};
wrongKey[symbolObj] = 'as a key';
console.log(wrongKey[symbolPrim]); // undefined
console.log(wrongKey['Symbol(key)']); // as a key

Date objects on the other hand are unique in that they have a “valueOf” value that’s not the date object itself. A date’s value is the same as it’s getTime() value which is a primitive number. Because of this, primitive conversions for date objects that would start with valueOf() would also end with valueOf(). This is especially troublesome for dates when dealing string concatenation which prefers valueOf() to toString().

To counter this, the Date type implements a Symbol.toPrimitive method which swaps the ordering of valueOf() and toString() for conversions with the “default” hint.

let dateObj = new Date(1979, 0);
console.log(dateObj.valueOf()); // 284014800000
console.log(dateObj.toString());
// Mon Jan 01 1979 00:00:00 GMT-0500 (Eastern Standard Time)
console.log(dateObj + '');
// Mon Jan 01 1979 00:00:00 GMT-0500 (Eastern Standard Time)

// show would-be default behavior without override
Object.defineProperty(dateObj, Symbol.toPrimitive, { value: null });

console.log(dateObj + ''); // 284014800000

After all is said and done, most of the time, for most objects, the path to becoming a string hinges on toString(). For your own objects, if you want to tweak the default implementation of toString() (as provided by Object), you can do that with Symbol.toStringTag. Otherwise, you can implement your own, custom toString() for something entirely different. As long as you’re not also implementing a valueOf() or working from a primitive object type (which is unlikely), that’s all there is to it.

For more complicated and controlled conversions you can use Symbol.toPrimitive. With the hint argument, you’ll even be able to distinguish between 3 different kinds of conversions - more than what’s possible with toString() and valueOf() alone. And even though a String.toPrimitive implementation would supersede toString() and valueOf(), they’re still good to implement in case anyone would want to call them directly.

class Chameleon {
    constructor (str, num, def) {
        this.str = str;
        this.num = num;
        this.def = def;
    }
    toString () {
        return this.str;
    }
    valueOf () {
        return this.num;
    }
    [Symbol.toPrimitive] (hint) {
        switch (hint) {
            case "string":
                return this.toString();
            case "number":
                return this.valueOf();
            default:
                return this.def;
        }
    }
}
let geeko = new Chameleon('SUSE', 15.1, 'Linux');
console.log(geeko == 'Linux'); // true

console.log('Major release: ' + Math.round(geeko));
// Major release: 15

let distros = {
  [geeko]: true
};
console.log(Object.keys(distros)); // ["SUSE"]

More info:


More tips: JavaScript Tips of the Day