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:
- If the primitive is already a string, return the string
- If the primitive is
undefined
return “undefined” - If the primitive is
null
return “null” - If the primitive is
true
return “true” - If the primitive is
false
return “false” - If the primitive is a number (Number or BigInt) return the string version of the number*
- 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
andNaN
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:
- 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:
- If the object has a
Symbol.toPrimitive
method, call it, passing in hint, capturing its return value asresult
- If
result
is a primitive returnresult
- Else throw an error
- If
- If the operation has a “string” hint, set primary to
toString()
and set secondary tovalueOf()
- Else set primary to
valueOf()
and set secondary totoString()
- If the object has a primary method, call it capturing its return value as
result
- If
result
is a primitive returnresult
- If
- If the object has a secondary method, call it capturing its return value as
result
- If
result
is a primitive returnresult
- If
- 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