Converting values to strings in JavaScript is more complicated than it might seem:
Can you spot the problem in the following code?
class UnexpectedValueError extends Error {
constructor(value) {
super('Unexpected value: ' + value); // (A)
}
}
For some values, this code throws an exception in line A:
> new UnexpectedValueError(Symbol())
TypeError: Cannot convert a Symbol value to a string
> new UnexpectedValueError({__proto__:null})
TypeError: Cannot convert object to primitive value
Read on for more information.
These are five common ways of converting a value v
to string:
String(v)
'' + v
`${v}`
v.toString()
{}.toString.call(v)
Which of these should we use? Let’s first check how they fare with the following tricky values:
undefined
null
Symbol()
{__proto__: null}
These are the results:
undefined |
null |
Symbol() |
{__proto__:null} |
|
---|---|---|---|---|
String(v) |
✔ | ✔ | ✔ | TypeError |
'' + v |
✔ | ✔ | TypeError |
TypeError |
`${v}` |
✔ | ✔ | TypeError |
TypeError |
v.toString() |
TypeError |
TypeError |
✔ | TypeError |
{}.toString.call(v) |
✔ | ✔ | ✔ | ✔ |
Conclusions:
{}.toString.call(v)
works for all tricky values.String(v)
is also a good solution.{}.toString.call(v)
mean? The following two expressions are equivalent:
{}.toString.call(v)
Object.prototype.toString.call(v)
We are invoking .toString()
but we are not using a receiver to find this method, we are invoking it directly (more information). This helps us with the three cases that fail not because of how .toString()
works, but because no method is found if we use those values as receivers of method calls:
undefined
null
{__proto__: null}
null
prototype tricky? It’s obvious why v.toString()
doesn’t work if there is no method .toString()
, but the first three conversion methods require one of three methods to be there and to return a primitive value.
> String({__proto__: null}) // no method available
TypeError: Cannot convert object to primitive value
> String({__proto__: null, [Symbol.toPrimitive]() {return 'YES'}})
'YES'
> String({__proto__: null, toString() {return 'YES'}})
'YES'
> String({__proto__: null, valueOf() {return 'YES'}})
'YES'
Interestingly, those methods returning undefined
or null
is fine, but an object is not:
> String({__proto__: null, toString() { return undefined }})
'undefined'
> String({__proto__: null, toString() { return null }})
'null'
> String({__proto__: null, toString() { return {} }})
TypeError: Cannot convert object to primitive value
class UnexpectedValueError extends Error {
constructor(value) {
super('Unexpected value: ' + {}.toString.call(value));
}
}
Now the code can handle all tricky values:
> new UnexpectedValueError(undefined).message
'Unexpected value: [object Undefined]'
> new UnexpectedValueError(null).message
'Unexpected value: [object Null]'
> new UnexpectedValueError(Symbol()).message
'Unexpected value: [object Symbol]'
> new UnexpectedValueError({__proto__:null}).message
'Unexpected value: [object Object]'
Plain objects have default string representations that are not very useful:
> String({a: 1})
'[object Object]'
Arrays have better string representations, but they still hide much information:
> String(['a', 'b'])
'a,b'
> String(['a', ['b']])
'a,b'
> String([1, 2])
'1,2'
> String(['1', '2'])
'1,2'
> String([true])
'true'
> String(['true'])
'true'
> String(true)
'true'
Stringifying functions returns their source code:
> String(function f() {return 4})
'function f() {return 4}'
We can override the built-in way of stringifying objects by implementing the method toString()
:
const obj = {
toString() {
return 'hello';
}
};
assert.equal(String(obj), 'hello');
JSON.stringify()
to convert values to strings The JSON data format is a text representation of JavaScript values. Therefore, JSON.stringify()
can also be used to convert values to strings. It works especially well for objects and Arrays where the normal conversion to string has significant deficiencies:
> JSON.stringify({a: 1})
'{"a":1}'
> JSON.stringify(['a', ['b']])
'["a",["b"]]'
JSON.stringify()
is OK with objects whose prototypes are null
:
> JSON.stringify({__proto__: null, a: 1})
'{"a":1}'
On major downside is that JSON.stringify()
only supports the following values:
null
NaN
and Infinity
)For most other values, we get undefined
as a result (and not a string):
> JSON.stringify(undefined)
undefined
> JSON.stringify(Symbol())
undefined
> JSON.stringify(() => {})
undefined
Bigints cause exceptions:
> JSON.stringify(123n)
TypeError: Do not know how to serialize a BigInt
Properties with undefined
-producing values are omitted:
> JSON.stringify({a: Symbol(), b: 2})
'{"b":2}'
Array elements whose values produce undefined
, are stringified as null
:
> JSON.stringify(['a', Symbol(), 'b'])
'["a",null,"b"]'
For more information, see section “Details on how JavaScript data is stringified” in “Exploring JavaScript”.
By default, JSON.stringify()
returns a single line of text. However the optional third parameter enables multiline output and lets us specify how much to indent – for example:
assert.equal(
JSON.stringify({first: 'Robin', last: 'Doe'}, null, 2),
`{
"first": "Robin",
"last": "Doe"
}`
);
JSON.stringify()
JSON.stringify()
is useful for displaying arbitrary strings:
Example:
const strWithNewlinesAndTabs = `
<-TAB
Second line
`;
console.log(JSON.stringify(strWithNewlinesAndTabs));
Output:
"\n\t<-TAB\nSecond line \n"
Console methods such as console.log()
tend to produce good output and have few limitations:
console.log({__proto__: null, prop: Symbol()});
Output:
[Object: null prototype] { prop: Symbol() }
However, by default, they only display objects up to a certain depth:
console.log({a: {b: {c: {d: true}}}});
Output:
{ a: { b: { c: [Object] } } }
Node.js lets us specify the depth for console.dir()
– with null
meaning infinite:
console.dir({a: {b: {c: {d: true}}}}, {depth: null});
Output:
{
a: { b: { c: { d: true } } }
}
In browsers, console.dir()
does not have an options object but lets us interactively and incrementally dig into objects.