{}.toString.call()
doesn’t produce useful results.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.
String(v)
v.toString()
'' + v
`${v}`
The following table shows how these operations fare with various values (#4 produces the same results as #3).
String(v) |
'' + v |
v.toString() |
|
---|---|---|---|
undefined |
'undefined' |
'undefined' |
TypeError |
null |
'null' |
'null' |
TypeError |
true |
'true' |
'true' |
'true' |
123 |
'123' |
'123' |
'123' |
123n |
'123' |
'123' |
'123' |
"abc" |
'abc' |
'abc' |
'abc' |
Symbol() |
'Symbol()' |
TypeError | 'Symbol()' |
{a:1} |
'[object Object]' |
'[object Object]' |
'[object Object]' |
['a'] |
'a' |
'a' |
'a' |
{__proto__:null} |
TypeError | TypeError | TypeError |
Symbol.prototype |
TypeError | TypeError | TypeError |
() => {} |
'() => {}' |
'() => {}' |
'() => {}' |
Let’s explore why some of these values produce exceptions or results that aren’t very useful.
Symbol()
Symbols must be converted to strings explicitly (via String()
or .toString()
). Conversion via concatenation throws an exception:
> '' + Symbol()
TypeError: Cannot convert a Symbol value to a string
Why is that? The intent is to prevent accidentally converting a symbol property key to a string (which is also a valid property key).
null
prototype It’s obvious why v.toString()
doesn’t work if there is no method .toString()
. However, the other conversion operations call the following methods in the following order and use the first primitive value that is returned (after converting it to string):
v[Symbol.toPrimitive]()
v.toString()
v.valueOf()
If none of these methods are present, a TypeError
is thrown.
> String({__proto__: null, [Symbol.toPrimitive]() {return 'YES'}})
'YES'
> String({__proto__: null, toString() {return 'YES'}})
'YES'
> String({__proto__: null, valueOf() {return 'YES'}})
'YES'
> String({__proto__: null}) // no method available
TypeError: Cannot convert object to primitive value
null
prototypes: dictionary objects Plain objects with null
prototypes work well as dictionaries:
const dict = Object.create(null);
// No special behavior with key '__proto__'
dict['__proto__'] = true;
assert.deepEqual(
Object.keys(dict), ['__proto__']
);
// No inherited properties
assert.equal(
'toString' in dict, false
);
assert.equal(
dict['toString'], undefined
);
For more information, see section “The pitfalls of using an object as a dictionary” in “Exploring JavaScript”.
null
prototypes in the standard library The standard library creates objects with null prototypes – e.g.:
The value of import.meta
:
assert.equal(
Object.getPrototypeOf(import.meta), null
);
The result of Object.groupBy()
:
const grouped = Object.groupBy([], x => x);
assert.equal(
Object.getPrototypeOf(grouped), null
);
When matching regular expressions – the value of matchObj.groups
:
const matchObj = /(?<group>x)/.exec('x');
assert.equal(
Object.getPrototypeOf(matchObj.groups), null
);
Symbol.prototype
You’ll probably never encounter the value Symbol.prototype
(the object that provides symbols with methods) in the wild but it’s an interesting edge case: Symbol.prototype[Symbol.toPrimitive]()
throws an exception if this
isn’t a symbol. That explains why converting Symbol.prototype
to a string doesn’t work:
> Symbol.prototype[Symbol.toPrimitive]()
TypeError: Symbol.prototype [ @@toPrimitive ] requires that 'this' be a Symbol
> String(Symbol.prototype)
TypeError: Symbol.prototype [ @@toPrimitive ] requires that 'this' be a Symbol
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'
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"]'
The following table summarizes the results of JSON.stringify(v)
:
JSON.stringify(v) |
|
---|---|
undefined |
undefined |
null |
'null' |
true |
'true' |
123 |
'123' |
123n |
TypeError |
'abc' |
'"abc"' |
Symbol() |
undefined |
{a:1} |
'{"a":1}' |
['a'] |
'["a"]' |
() => {} |
undefined |
{__proto__:null} |
'{}' |
Symbol.prototype |
'{}' |
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"
JSON.stringify()
does not support circular data JSON.stringify()
throws an exception if data is circular:
const cycle = {};
cycle.prop = cycle;
assert.throws(
() => JSON.stringify(cycle),
/^TypeError: Converting circular structure to JSON/
);
Alas, there are no good built-in solutions for stringification that work all the time. In this section, we’ll explore a short function that works for all simple use cases, along with solutions for more sophisticated use cases.
toString()
function What would a simple solution for stringification look like?
JSON.stringify()
works well for a lot of data, especially plain objects and Arrays. If it can’t stringify a given value, it returns undefined
instead of a string – unless the value is a bigint. Then it throws an exception.
Therefore, we can use the following function for stringification:
function toString(v) {
if (typeof v === 'bigint') {
return v + 'n';
}
return JSON.stringify(v) ?? String(v); // (A)
}
For values that are not supported by JSON.stringify()
, we use String()
as a fallback (line A). That function only throws for the following two values – which are both handled well by JSON.stringify()
:
{__proto__:null}
Symbol.prototype
The following table summarizes the results of toString()
:
toString() |
|
---|---|
undefined |
'undefined' |
null |
'null' |
true |
'true' |
123 |
'123' |
123n |
'123n' |
'abc' |
'"abc"' |
Symbol() |
'Symbol()' |
{a:1} |
'{"a":1}' |
['a'] |
'["a"]' |
() => {} |
'() => {}' |
{__proto__:null} |
'{}' |
Symbol.prototype |
'{}' |
JSON.stringify
just without all the double-quotes”Node.js has several built-in functions that provide sophisticated support for converting JavaScript values to strings – e.g.:
util.inspect(obj)
“returns a string representation of obj
that is intended for debugging”.util.format(format, ...args)
“returns a formatted string using the first argument as a printf-like format string which can contain zero or more format specifiers”.These functions can even handle circular data:
const cycle = {};
cycle.prop = cycle;
assert.equal(
util.inspect(cycle),
'<ref *1> { prop: [Circular *1] }'
);
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 descend into objects.
We can customize the built-in way of stringifying objects by implementing the method .toString()
:
const helloObj = {
toString() {
return 'Hello!';
}
};
assert.equal(
String(helloObj), 'Hello!'
);
We can customize how an object is converted to JSON by implementing the method .toJSON()
:
const point = {
x: 1,
y: 2,
toJSON() {
return [this.x, this.y];
}
}
assert.equal(
JSON.stringify(point), '[1,2]'
);