Type coercion in JavaScript

[2019-10-31] dev, javascript, es spec
(Ad, please don’t block)

In this blog post, we examine the role of type coercion in JavaScript. We will go relatively deeply into this subject and, e.g., look into how the ECMAScript specification handles coercion.

What is type coercion?  

Each operation (function, operator, etc.) expects its parameters to have certain types. If a value doesn’t have the right type for a parameter, the two most common options for a caller are:

  • They can explicitly convert the value so that it has the right type. For example, in the following interaction, we want to multiply two numbers that are written out in strings:
    > Number('3') * Number('2')
    6
    
  • They can let the operation make the conversion for them:
    > '3' * '2'
    6
    
    This kind of implicit conversion is called coercion.

JavaScript initially didn’t have exceptions, which is why it uses coercion and error values for most of its operations:

// Coercion
assert.equal(3 / true, 3);

// Error values
assert.equal(1 / 0, Infinity);
assert.equal(Number('xyz'), NaN);

However, there are also cases (especially when it comes to newer features) where it throws exceptions if an argument doesn’t have the right type:

  • Accessing properties of null or undefined:

    > undefined.prop
    TypeError: Cannot read property 'prop' of undefined
    > null.prop
    TypeError: Cannot read property 'prop' of null
    > 'prop' in null
    TypeError: Cannot use 'in' operator to search for 'prop' in null
    
  • Using symbols:

    > 6 / Symbol()
    TypeError: Cannot convert a Symbol value to a number
    
  • Mixing bigints and numbers:

    > 6 / 3n
    TypeError: Cannot mix BigInt and other types, use explicit conversions
    
  • New-calling or function-calling values that don’t support that operation:

    > 123()
    TypeError: 123 is not a function
    > (class {})()
    TypeError: Class constructor  cannot be invoked without 'new'
    
    > new 123
    TypeError: 123 is not a constructor
    > new (() => {})
    TypeError: (intermediate value) is not a constructor
    
  • Changing read-only properties (only throws in strict mode):

    > 'abc'.length = 1
    TypeError: Cannot assign to read only property 'length' of string 'abc'
    > Object.freeze({prop:3}).prop = 1
    TypeError: Cannot assign to read only property 'prop' of object '#<Object>'
    

How internal type conversion functions are used in the ECMAScript specification  

The following sections describe the most important internal functions used by the ECMAScript specification to convert actual parameters to expected types.

For example, in TypeScript, you would write:

function multiply(leftValue: number, rightValue: number) {
  // ···
}

In the specification, this looks as follows (translated to JavaScript, so that it is easier to understand):

function multiply(leftValue, rightValue) {
  let lnum = ToNumeric(leftValue);
  let rnum = ToNumeric(rightValue);
  // ···
}

Converting to primitive types and objects  

Whenever primitive types or objects are expected, the following conversion functions are used:

  • ToBoolean()
  • ToNumber()
  • ToBigInt()
  • ToString()
  • ToObject()

These internal functions have analogs in JavaScript that are very similar:

> Boolean(0)
false
> Boolean(1)
true

> Number('123')
123

Because we now have bigints in addition to numbers, the specification often uses ToNumeric() where it previously used ToNumber(). Read on for more information.

Converting to numeric types  

  • ToNumeric() is used whenever either a number or a bigint is expected. Execution is then forwarded to Type(result)::operation(···) (think method of the type of result).
  • ToInteger(x) is used whenever a number without a fraction is expected. The range of the result is often restricted further afterwards.
    • It uses ToNumber(x) and removes the fraction (similar to Math.trunc()).
  • ToInt32(), ToUint32() coerce numbers to 32-bit integers and are used by bitwise operators (see table below).
    • ToInt32(): signed, range [−2^31^, 2^31^−1] (limits are included)
    • ToUint32(): unsigned (hence the U), range [0, 2^32^−1] (limits are included)

Coercion of bitwise operators for numbers (BigInt operators don’t limit the number of bits).

operand left operand right operand result
<< ToInt32() ToUint32() Int32
signed >> ToInt32() ToUint32() Int32
unsigned >>> ToInt32() ToUint32() Uint32
&, ^, ¦ ToInt32() ToUint32() Int32
~ ToInt32() Int32

Converting to property keys  

ToPropertyKey() returns a string or a symbol and is used by:

  • The bracket operator []
  • Computed property keys in object literals
  • The left-hand side of the in operator
  • Object.defineProperty(_, P, _)
  • Object.fromEntries()
  • Object.getOwnPropertyDescriptor()
  • Object.prototype.hasOwnProperty()
  • Object.prototype.propertyIsEnumerable()
  • Several methods of Reflect

Converting to Array indices  

  • ToLength() is used (directly) mainly for string indices.
    • Helper function for ToIndex()
    • Range of result l: 0 ≤ l ≤ 2^53^−1
  • ToIndex() is used for Typed Array indices.
    • Main difference with ToLength(): throws an exception if argument is out of range.
    • Range of result i: 0 ≤ i ≤ 2^53^−1
  • ToUint32() is used for Array indices.
    • Range of result i: 0 ≤ i < 2^32^−1 (the upper limit is excluded, to leave room for the .length)

Converting to Typed Array elements  

When you set the value of a Typed Array element, one of the following conversion functions is used:

  • ToInt8()
  • ToUint8()
  • ToUint8Clamp()
  • ToInt16()
  • ToUint16()
  • ToInt32()
  • ToUint32()
  • ToBigInt64()
  • ToBigUint64()

Intermission: expressing specification algorithms in JavaScript  

In the remainder of the post, we’ll encounter several specification algorithms, but “implemented” as JavaScript.

  • Spec: If Type(value) is String
    • JavaScript: if (TypeOf(value) === 'string') (very loose translation, defined below)
  • Spec: If IsCallable(method) is true
    • JavaScript: if (IsCallable(method)) (defined below)
  • Spec: Let numValue be ToNumber(value)
    • JavaScript: let numValue = Number(value)
  • Spec: Let isArray be IsArray(O)
    • JavaScript: let isArray = Array.isArray(O)
  • Spec: If O has a [[NumberData]] internal slot
    • JavaScript: if ('__NumberData__' in O)
  • Spec: Let tag be Get(O, @@toStringTag)
    • JavaScript: let tag = O[Symbol.toStringTag]
  • Spec: Return the string-concatenation of "[object ", tag, and "]".
    • JavaScript: return '[object ' + tag + ']';

Some things are omitted – for example, the ReturnIfAbrupt shorthands ? and !.

/**
 * An improved version of typeof
 */
function TypeOf(value) {
  const result = typeof value;
  switch (result) {
    case 'function':
      return 'object';
    case 'object':
      if (value === null) {
        return 'null';
      } else {
        return 'object';
      }
    default:
      return result;
  }
}

function IsCallable(x) {
  return typeof x === 'function';
}

Example coercion algorithms  

ToPrimitive()  

The operation ToPrimitive() is an intermediate step for many coercion algorithms (some of which we’ll see later in this post). It converts an arbitrary values to primitive values.

ToPrimitive() is used often in the spec because many operations (eventually) only work with primitive values. For example, we can use the plus operator (+) to add numbers and to concatenate strings, but we can’t use it to concatenate Arrays.

This is what the JavaScript version of ToPrimitive() looks like:

function ToPrimitive(input: any,
  hint: 'default' | 'string' | 'number' = 'default') {
    if (TypeOf(input) === 'object') {
      let exoticToPrim = input[Symbol.toPrimitive]; // (A)
      if (exoticToPrim !== undefined) {
        let result = exoticToPrim.call(input, hint);
        if (TypeOf(result) !== 'object') {
          return result;
        }
        throw new TypeError();
      }
      if (hint === 'default') {
        hint = 'number';
      }
      return OrdinaryToPrimitive(input, hint);
    } else {
      // input is already primitive
      return input;
    }
  }

hint can have one of three values:

  • 'number' means: if possible, input should be converted to a number.
  • 'string' means: if possible, input should be converted to a string.
  • 'default' means: there is no preference for either numbers or strings.

ToPrimitive() lets objects override the conversion to primitive via Symbol.toPrimitive. If an object doesn’t do that, it is passed on to OrdinaryToPrimitive():

function OrdinaryToPrimitive(O: object, hint: 'string' | 'number') {
  let methodNames;
  if (hint === 'string') {
    methodNames = ['toString', 'valueOf'];
  } else {
    methodNames = ['valueOf', 'toString'];
  }
  for (let name of methodNames) {
    let method = O[name];
    if (IsCallable(method)) {
      let result = method.call(O);
      if (TypeOf(result) !== 'object') {
        return result;
      }
    }
  }
  throw new TypeError();
}

Property keys used by ToPrimitive() and OrdinaryToPrimitive()  

Three property keys are relevant for the conversion to primitive values:

  • 'toString' is preferred when hint indicates that we’d like the primitive value to be a string.
  • 'valueOf' is preferred when hint indicates that we’d like the primitive value to be a number.
  • Symbol.toPrimitive is for customizing the conversion to primitive. That is only done twice in the standard library:
    • Symbol.prototype[@@toPrimitive](hint)
      • Returns the wrapped symbol.
      • Exists to support two use cases:
        • Symbols have a method .toString() that returns a string.
        • Instances of Symbol should not be accidentally converted to strings.
    • Date.prototype[@@toPrimitive](hint)
      • Explained in more detail soon.

Let’s examine how hint influences which property key is used:

  • Number() calls ToPrimitive() with hint set to 'number':
    > Number({valueOf() {return 1}, toString() {return 'a'}})
    1
    
  • String() calls ToPrimitive() with hint set to 'string':
    > String({valueOf() {return 1}, toString() {return 'a'}})
    'a'
    

How do callers of ToPrimitive() specify hint?  

These are a few examples of how various operations use ToPrimitive():

  • hint === 'number'. The following operations prefer numbers:
    • ToNumeric()
    • ToNumber()
    • ToBigInt(), BigInt()
    • Abstract Relational Comparison (<)
  • hint === 'string'. The following operations prefer strings:
    • ToString()
    • ToPropertyKey()
  • hint === 'default'. The following operations are neutral w.r.t. the type of the returned primitive value:
    • Abstract Equality Comparison (==)
    • Addition Operator (+)
    • new Date(value) (value can be either a number or a string)

As we have seen, the default behavior is for 'default' being handled as if it were 'number'. Only instances of Symbol and Date override this behavior.

Date.prototype[Symbol.toPrimitive]()  

This is how Dates handle being converted to primitive values:

Date.prototype[Symbol.toPrimitive] = function (
  hint: 'default' | 'string' | 'number') {
    let O = this;
    if (TypeOf(O) !== 'object') {
      throw new TypeError();
    }
    let tryFirst;
    if (hint === 'string' || hint === 'default') {
      tryFirst = 'string';
    } else if (hint === 'number') {
      tryFirst = 'number';
    } else {
      throw new TypeError();
    }
    return OrdinaryToPrimitive(O, tryFirst);
  };

The only difference with the default algorithm is that 'default' becomes 'string' (and not 'number'). This can be observed if we use operations that set hint to 'default':

  • == coerces a Date to a string if the other operand is a primitive value other than undefined, null, and boolean:

    > const d = new Date('2222-03-27');
    > d == 'Wed Mar 27 2222 01:00:00 GMT+0100 (Central European Standard Time)'
    true
    
  • If the first operand of + is a number, we can see that the second operand was coerced to a string because the result was computed via string concatenation (not via numeric addition):

    > 123 + d
    '123Wed Mar 27 2222 01:00:00 GMT+0100 (Central European Standard Time)'
    

This is the JavaScript version of ToString():

function ToString(argument) {
  if (argument === undefined) {
    return 'undefined';
  } else if (argument === null) {
    return 'null';
  } else if (argument === true) {
    return 'true';
  } else if (argument === false) {
    return 'false';
  } else if (TypeOf(argument) === 'number') {
    return Number.toString(argument);
  } else if (TypeOf(argument) === 'string') {
    return argument;
  } else if (TypeOf(argument) === 'symbol') {
    throw new TypeError();
  } else if (TypeOf(argument) === 'bigint') {
    return BigInt.toString(argument);
  } else {
    // argument is an object
    let primValue = ToPrimitive(argument, 'string'); // (A)
    return ToString(primValue);
  }
}

Note how this function uses ToPrimitive() as an intermediate step before converting the primitive result to a string (line A).

ToString() deviates in an interesting way from how String() works: If argument is a symbol, the former throws a TypeError while the latter doesn’t. Why is that? The default for symbols is that converting them to strings throws exceptions:

> const sym = Symbol('sym');

> ''+sym
TypeError: Cannot convert a Symbol value to a string
> `${sym}`
TypeError: Cannot convert a Symbol value to a string

That default is overridden in String() and Symbol.prototype.toString() (both are described in the next sections):

> String(sym)
'Symbol(sym)'
> sym.toString()
'Symbol(sym)'

String()  

function String(value) {
  let s;
  if (value === undefined) {
    s = '';
  } else {
    if (new.target === undefined && TypeOf(value) === 'symbol') { // (A)
      return SymbolDescriptiveString(value);
    }
    s = ToString(value);
  }
  if (new.target === undefined) {
    // Function call
    return s;
  }
  // New call
  return StringCreate(s, new.target.prototype); // simplified!
}

In line A, we can see what happens if String() is function-called and its argument is a symbol. We can also see that String() works differently if it function-called and if it is new-called. The helper function StringCreate() and SymbolDescriptiveString() are shown next.

function StringCreate(value, prototype) {
  // Create a new String instance that has the given prototype
}
function SymbolDescriptiveString(sym) {
  assert.equal(TypeOf(sym), 'symbol');
  let desc = sym.description;
  if (desc === undefined) {
    desc = '';
  }
  assert.equal(TypeOf(desc), 'string');
  return 'Symbol('+desc+')';
}

Symbol.prototype.toString()  

In addition to String(), you can also use method .toString() to convert a symbol to a string. Its specification looks as follows.

Symbol.prototype.toString = function () {
  let sym = thisSymbolValue(this);
  return SymbolDescriptiveString(sym);
};
function thisSymbolValue(value) {
  if (TypeOf(value) === 'symbol') {
    return value;
  }
  if (TypeOf(value) === 'object' && '__SymbolData__' in value) {
    let s = value.__SymbolData__;
    assert.equal(TypeOf(s), 'symbol');
    return s;
  }
}

Object.prototype.toString  

The default specification for .toString() looks as follows:

Object.prototype.toString = function () {
  if (this === undefined) {
    return '[object Undefined]';
  }
  if (this === null) {
    return '[object Null]';
  }
  let O = ToObject(this);
  let isArray = Array.isArray(O);
  let builtinTag;
  if (isArray) {
    builtinTag = 'Array';
  } else if ('__ParameterMap__' in O) {
    builtinTag = 'Arguments';
  } else if ('__Call__' in O) {
    builtinTag = 'Function';
  } else if ('__ErrorData__' in O) {
    builtinTag = 'Error';
  } else if ('__BooleanData__' in O) {
    builtinTag = 'Boolean';
  } else if ('__NumberData__' in O) {
    builtinTag = 'Number';
  } else if ('__StringData__' in O) {
    builtinTag = 'String';
  } else if ('__DateValue__' in O) {
    builtinTag = 'Date';
  } else if ('__RegExpMatcher__' in O) {
    builtinTag = 'RegExp';
  } else {
    builtinTag = 'Object';
  }
  let tag = O[Symbol.toStringTag];
  if (TypeOf(tag) !== 'string') {
    tag = builtinTag;
  }
  return '[object ' + tag + ']';
};

This operation is used if you convert plain objects to strings:

> String({})
'[object Object]'

By default, it is also used if you convert instances of classes to strings:

class MyClass {}
assert.equal(
  String(new MyClass()), '[object Object]');

You can configure what comes after “object” inside the square brackets:

class MyClass {}
MyClass.prototype[Symbol.toStringTag] = 'Custom!';
assert.equal(
  String(new MyClass()), '[object Custom!]');

If you call Object.prototype.toString directly, you can access the overridden behavior:

> Object.prototype.toString.call(['a', 'b'])
'[object Array]'
> String(['a', 'b'])
'a,b'

ToPropertyKey()  

ToPropertyKey() is used by, among others, the bracket operator. This is how it works:

function ToPropertyKey(argument) {
  let key = ToPrimitive(argument, 'string'); // (A)
  if (TypeOf(key) === 'symbol') {
    return key;
  }
  return ToString(key);
}

Once again, objects are converted to primitives before working with primitives.

ToNumeric() is used by, among others, by the multiplication operator (*). This is how it works:

function ToNumeric(value) {
  let primValue = ToPrimitive(value, 'number');
  if (TypeOf(primValue) === 'bigint') {
    return primValue;
  }
  return ToNumber(primValue);
}

ToNumber()  

ToNumber() works as follows:

function ToNumber(argument) {
  if (argument === undefined) {
    return NaN;
  } else if (argument === null) {
    return +0;
  } else if (argument === true) {
    return 1;
  } else if (argument === false) {
    return +0;
  } else if (TypeOf(argument) === 'number') {
    return argument;
  } else if (TypeOf(argument) === 'string') {
    return parseTheString(argument); // not shown here
  } else if (TypeOf(argument) === 'symbol') {
    throw new TypeError();
  } else if (TypeOf(argument) === 'bigint') {
    throw new TypeError();
  } else {
    // argument is an object
    let primValue = ToPrimitive(argument, 'number');
    return ToNumber(primValue);
  }
}

The structure of ToNumber() is similar to the structure of ToString().

Operations that coerce  

Addition operator (+)  

This is how JavaScript’s addition operator is specified:

function Addition(leftHandSide, rightHandSide) {
  let lprim = ToPrimitive(leftHandSide);
  let rprim = ToPrimitive(rightHandSide);
  if (TypeOf(lprim) === 'string' || TypeOf(rprim) === 'string') { // (A)
    return ToString(lprim) + ToString(rprim);
  }
  let lnum = ToNumeric(lprim);
  let rnum = ToNumeric(rprim);
  if (TypeOf(lnum) !== TypeOf(rnum)) {
    throw new TypeError();
  }
  let T = Type(lnum);
  return T.add(lnum, rnum); // (B)
}

Steps of this algorithm:

  • Both operands are converted to primitive values.
  • If one of the results is a string, both are converted to strings and concatenated (line A).
  • Otherwise, both operands are converted to numeric values and added (line B).

Abstract Equality Comparison (==)  

/** Loose equality (==) */
function abstractEqualityComparison(x, y) {
  if (TypeOf(x) === TypeOf(y)) {
    // Use strict equality (===)
    return strictEqualityComparison(x, y);
  }

  // Comparing null with undefined
  if (x === null && y === undefined) {
    return true;
  }
  if (x === undefined && y === null) {
    return true;
  }

  // Comparing a number and a string
  if (TypeOf(x) === 'number' && TypeOf(y) === 'string') {
    return abstractEqualityComparison(x, Number(y));
  }
  if (TypeOf(x) === 'string' && TypeOf(y) === 'number') {
    return abstractEqualityComparison(Number(x), y);
  }

  // Comparing a bigint and a string
  if (TypeOf(x) === 'bigint' && TypeOf(y) === 'string') {
    let n = StringToBigInt(y);
    if (Number.isNaN(n)) {
      return false;
    }
    return abstractEqualityComparison(x, n);
  }
  if (TypeOf(x) === 'string' && TypeOf(y) === 'bigint') {
    return abstractEqualityComparison(y, x);
  }

  // Comparing a boolean with a non-boolean
  if (TypeOf(x) === 'boolean') {
    return abstractEqualityComparison(Number(x), y);
  }
  if (TypeOf(y) === 'boolean') {
    return abstractEqualityComparison(x, Number(y));
  }

  // Comparing an object with a primitive
  // (other than undefined, null, a boolean)
  if (['string', 'number', 'bigint', 'symbol'].includes(TypeOf(x))
    && TypeOf(y) === 'object') {
      return abstractEqualityComparison(x, ToPrimitive(y));
    }
  if (TypeOf(x) === 'object'
    && ['string', 'number', 'bigint', 'symbol'].includes(TypeOf(y)) {
      return abstractEqualityComparison(ToPrimitive(x), y);
    }
  
  // Comparing a bigint with a number
  if ((TypeOf(x) === 'bigint' && TypeOf(y) === 'number')
    || (TypeOf(x) === 'number' && TypeOf(y) === 'bigint')) {
      if ([NaN, +Infinity, -Infinity].includes(x)
        || [NaN, +Infinity, -Infinity].includes(y)) {
          return false;
        }
      if (isSameMathematicalValue(x, y)) {
        return true;
      } else {
        return false;
      }
    }
  
  return false;
}

The following operations are not shown here:

Now that we have taken a closer look at how JavaScript’s type coercion works, let’s conclude with a brief glossary of terms related to type conversion:

  • In type conversion, we want the output value to have a given type. If the input value already has that type, it is simply returned unchanged. Otherwise, it is converted to a value that has the desired type.

  • Explicit type conversion means that the programmer uses an operation (a function, an operator, etc.) to trigger a type conversion. Explicit conversions can be:

    • Checked: If a value can’t be converted, an exception is thrown.
    • Unchecked: If a value can’t be converted, an error value is returned.
  • What type casting is, depends on the programming language. For example, in Java, it is explicit checked type conversion.

  • Type coercion is implicit type conversion: An operation automatically converts its arguments to the types it needs. Can be checked or unchecked or something in-between.

[Source: Wikipedia]