Converting values to strings in JavaScript

[2025-04-29] dev, javascript
(Ad, please don’t block)

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.

Converting values to strings  

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.

Five ways of converting values to strings  

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:

  • Among the five approaches listed in the table only {}.toString.call(v) works for all tricky values.
  • If we don’t need to be 100% safe and would like to be less verbose then String(v) is also a good solution.

What does {}.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}

Why is an object with a 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

Fixing the problematic example  

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

Converting objects to strings  

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}'

Customizing the stringification of objects  

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

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"]'

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"

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 dig into objects.