Converting values to strings in JavaScript has pitfalls

[2025-04-29] dev, javascript
(Ad, please don’t block)
  • Update 2025-05-25: I rewrote most of this chapter – in reaction to Emanuel Kluge pointing out to me that {}.toString.call() doesn’t produce useful results.

Converting values to strings in JavaScript is more complicated than it might seem:

  • Most approaches have values they can’t handle.
  • We don’t always see all of the data.

Example: code that is problematic  

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.

Four common ways of converting values to strings  

  1. String(v)
  2. v.toString()
  3. '' + v
  4. `${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.

Tricky value: 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).

Tricky value: an object with a 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

Use case for 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
);

Tricky value: 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

Tricky values: objects  

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'

Using 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:

  • Primitive values:
    • null
    • Booleans
    • Numbers (except for NaN and Infinity)
    • Strings
  • Non-primitive values:
    • Arrays
    • Objects (except for functions)

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”.

Multiline output  

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"
}`
);

Displaying strings via JSON.stringify()  

JSON.stringify() is useful for displaying arbitrary strings:

  • The result always fits into a single line.
  • Invisible characters such as newlines and tabs become visible.

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/
);

Solutions  

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.

Short solution: a custom 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 '{}'

Library for stringifying values  

  • The library stringify-object by Sindre Sorhus: “Stringify an object/array like JSON.stringify just without all the double-quotes”

Node.js functions for stringifying values  

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] }'
);

Alternative to stringification: logging data to the console  

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.

Customizing how objects are converted to strings  

Customizing the string conversion of 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!'
);

Customizing the conversion to JSON  

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]'
);