2.4 Example coercion algorithms

2.4.1 ToPrimitive()

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

ToPrimitive() is used often in the spec because most operators can only work with primitive values. For example, we can use the addition 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:

  1. /**
  2. * @param hint Which type is preferred for the result:
  3. * string, number, or don’t care?
  4. */
  5. function ToPrimitive(input: any,
  6. hint: 'string'|'number'|'default' = 'default') {
  7. if (TypeOf(input) === 'object') {
  8. let exoticToPrim = input[Symbol.toPrimitive]; // (A)
  9. if (exoticToPrim !== undefined) {
  10. let result = exoticToPrim.call(input, hint);
  11. if (TypeOf(result) !== 'object') {
  12. return result;
  13. }
  14. throw new TypeError();
  15. }
  16. if (hint === 'default') {
  17. hint = 'number';
  18. }
  19. return OrdinaryToPrimitive(input, hint);
  20. } else {
  21. // input is already primitive
  22. return input;
  23. }
  24. }

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

  1. function OrdinaryToPrimitive(O: object, hint: 'string' | 'number') {
  2. let methodNames;
  3. if (hint === 'string') {
  4. methodNames = ['toString', 'valueOf'];
  5. } else {
  6. methodNames = ['valueOf', 'toString'];
  7. }
  8. for (let name of methodNames) {
  9. let method = O[name];
  10. if (IsCallable(method)) {
  11. let result = method.call(O);
  12. if (TypeOf(result) !== 'object') {
  13. return result;
  14. }
  15. }
  16. }
  17. throw new TypeError();
  18. }
2.4.1.1 Which hints do callers of ToPrimitive() use?

The parameter 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.

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 (shown later).

2.4.1.2 Which methods are called to convert objects to Primitives?

If the conversion to primitive isn’t overridden via Symbol.toPrimitive, OrdinaryToPrimitive() calls either or both of the following two methods:

  • 'toString' is called first if hint indicates that we’d like the primitive value to be a string.
  • 'valueOf' is called first if hint indicates that we’d like the primitive value to be a number.

The following code demonstrates how that works:

  1. const obj = {
  2. toString() { return 'a' },
  3. valueOf() { return 1 },
  4. };
  5. // String() prefers strings:
  6. assert.equal(String(obj), 'a');
  7. // Number() prefers numbers:
  8. assert.equal(Number(obj), 1);

A method with the property key Symbol.toPrimitive overrides the normal conversion to primitive. That is only done twice in the standard library:

  • Symbol.prototype[Symbol.toPrimitive](hint)
    • If the receiver is an instance of Symbol, this method always returns the wrapped symbol.
    • The rationale is that instances of Symbol have a .toString() method that returns strings. But even if hint is 'string', .toString() should not be called so that we don’t accidentally convert instances of Symbol to strings (which are a completely different kind of property key).
  • Date.prototype[Symbol.toPrimitive](hint)
    • Explained in more detail next.
2.4.1.3 Date.prototype[Symbol.toPrimitive]()

This is how Dates handle being converted to primitive values:

  1. Date.prototype[Symbol.toPrimitive] = function (
  2. hint: 'default' | 'string' | 'number') {
  3. let O = this;
  4. if (TypeOf(O) !== 'object') {
  5. throw new TypeError();
  6. }
  7. let tryFirst;
  8. if (hint === 'string' || hint === 'default') {
  9. tryFirst = 'string';
  10. } else if (hint === 'number') {
  11. tryFirst = 'number';
  12. } else {
  13. throw new TypeError();
  14. }
  15. return OrdinaryToPrimitive(O, tryFirst);
  16. };

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

  • The == operator coerces objects to primitives (with a default hint) if the other operand is a primitive value other than undefined, null, and boolean. In the following interaction, we can see that the result of coercing the date is a string:

    1. const d = new Date('2222-03-27');
    2. assert.equal(
    3. d == 'Wed Mar 27 2222 01:00:00 GMT+0100'
    4. + ' (Central European Standard Time)',
    5. true);
  • The + operator coerces both operands to primitives (with a default hint). If one of the results is a string, it performs string concatenation (otherwise it performs numeric addition). In the following interaction, we can see that the result of coercing the date is a string because the operator returns a string.

    1. const d = new Date('2222-03-27');
    2. assert.equal(
    3. 123 + d,
    4. '123Wed Mar 27 2222 01:00:00 GMT+0100'
    5. + ' (Central European Standard Time)');

2.4.2 ToString() and related operations

This is the JavaScript version of ToString():

  1. function ToString(argument) {
  2. if (argument === undefined) {
  3. return 'undefined';
  4. } else if (argument === null) {
  5. return 'null';
  6. } else if (argument === true) {
  7. return 'true';
  8. } else if (argument === false) {
  9. return 'false';
  10. } else if (TypeOf(argument) === 'number') {
  11. return Number.toString(argument);
  12. } else if (TypeOf(argument) === 'string') {
  13. return argument;
  14. } else if (TypeOf(argument) === 'symbol') {
  15. throw new TypeError();
  16. } else if (TypeOf(argument) === 'bigint') {
  17. return BigInt.toString(argument);
  18. } else {
  19. // argument is an object
  20. let primValue = ToPrimitive(argument, 'string'); // (A)
  21. return ToString(primValue);
  22. }
  23. }

Note how this function uses ToPrimitive() as an intermediate step for objects, 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:

  1. > const sym = Symbol('sym');
  2. > ''+sym
  3. TypeError: Cannot convert a Symbol value to a string
  4. > `${sym}`
  5. 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 subsections):

  1. > String(sym)
  2. 'Symbol(sym)'
  3. > sym.toString()
  4. 'Symbol(sym)'
2.4.2.1 String()
  1. function String(value) {
  2. let s;
  3. if (value === undefined) {
  4. s = '';
  5. } else {
  6. if (new.target === undefined && TypeOf(value) === 'symbol') {
  7. // This function was function-called and value is a symbol
  8. return SymbolDescriptiveString(value);
  9. }
  10. s = ToString(value);
  11. }
  12. if (new.target === undefined) {
  13. // This function was function-called
  14. return s;
  15. }
  16. // This function was new-called
  17. return StringCreate(s, new.target.prototype); // simplified!
  18. }

String() works differently, depending on whether it is invoked via a function call or via new. It uses new.target to distinguish the two.

These are the helper functions StringCreate() and SymbolDescriptiveString():

  1. /**
  2. * Creates a String instance that wraps `value`
  3. * and has the given protoype.
  4. */
  5. function StringCreate(value, prototype) {
  6. // ···
  7. }
  8. function SymbolDescriptiveString(sym) {
  9. assert.equal(TypeOf(sym), 'symbol');
  10. let desc = sym.description;
  11. if (desc === undefined) {
  12. desc = '';
  13. }
  14. assert.equal(TypeOf(desc), 'string');
  15. return 'Symbol('+desc+')';
  16. }
2.4.2.2 Symbol.prototype.toString()

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

  1. Symbol.prototype.toString = function () {
  2. let sym = thisSymbolValue(this);
  3. return SymbolDescriptiveString(sym);
  4. };
  5. function thisSymbolValue(value) {
  6. if (TypeOf(value) === 'symbol') {
  7. return value;
  8. }
  9. if (TypeOf(value) === 'object' && '__SymbolData__' in value) {
  10. let s = value.__SymbolData__;
  11. assert.equal(TypeOf(s), 'symbol');
  12. return s;
  13. }
  14. }
2.4.2.3 Object.prototype.toString

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

  1. Object.prototype.toString = function () {
  2. if (this === undefined) {
  3. return '[object Undefined]';
  4. }
  5. if (this === null) {
  6. return '[object Null]';
  7. }
  8. let O = ToObject(this);
  9. let isArray = Array.isArray(O);
  10. let builtinTag;
  11. if (isArray) {
  12. builtinTag = 'Array';
  13. } else if ('__ParameterMap__' in O) {
  14. builtinTag = 'Arguments';
  15. } else if ('__Call__' in O) {
  16. builtinTag = 'Function';
  17. } else if ('__ErrorData__' in O) {
  18. builtinTag = 'Error';
  19. } else if ('__BooleanData__' in O) {
  20. builtinTag = 'Boolean';
  21. } else if ('__NumberData__' in O) {
  22. builtinTag = 'Number';
  23. } else if ('__StringData__' in O) {
  24. builtinTag = 'String';
  25. } else if ('__DateValue__' in O) {
  26. builtinTag = 'Date';
  27. } else if ('__RegExpMatcher__' in O) {
  28. builtinTag = 'RegExp';
  29. } else {
  30. builtinTag = 'Object';
  31. }
  32. let tag = O[Symbol.toStringTag];
  33. if (TypeOf(tag) !== 'string') {
  34. tag = builtinTag;
  35. }
  36. return '[object ' + tag + ']';
  37. };

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

  1. > String({})
  2. '[object Object]'

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

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

Normally, we would override .toString() in order to configure the string representation of MyClass, but we can also change what comes after “object” inside the string with the square brackets:

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

It is interesting to compare the overriding versions of .toString() with the original version in Object.prototype:

  1. > ['a', 'b'].toString()
  2. 'a,b'
  3. > Object.prototype.toString.call(['a', 'b'])
  4. '[object Array]'
  5. > /^abc$/.toString()
  6. '/^abc$/'
  7. > Object.prototype.toString.call(/^abc$/)
  8. '[object RegExp]'

2.4.3 ToPropertyKey()

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

  1. function ToPropertyKey(argument) {
  2. let key = ToPrimitive(argument, 'string'); // (A)
  3. if (TypeOf(key) === 'symbol') {
  4. return key;
  5. }
  6. return ToString(key);
  7. }

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

2.4.4 ToNumeric() and related operations

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

  1. function ToNumeric(value) {
  2. let primValue = ToPrimitive(value, 'number');
  3. if (TypeOf(primValue) === 'bigint') {
  4. return primValue;
  5. }
  6. return ToNumber(primValue);
  7. }
2.4.4.1 ToNumber()

ToNumber() works as follows:

  1. function ToNumber(argument) {
  2. if (argument === undefined) {
  3. return NaN;
  4. } else if (argument === null) {
  5. return +0;
  6. } else if (argument === true) {
  7. return 1;
  8. } else if (argument === false) {
  9. return +0;
  10. } else if (TypeOf(argument) === 'number') {
  11. return argument;
  12. } else if (TypeOf(argument) === 'string') {
  13. return parseTheString(argument); // not shown here
  14. } else if (TypeOf(argument) === 'symbol') {
  15. throw new TypeError();
  16. } else if (TypeOf(argument) === 'bigint') {
  17. throw new TypeError();
  18. } else {
  19. // argument is an object
  20. let primValue = ToPrimitive(argument, 'number');
  21. return ToNumber(primValue);
  22. }
  23. }

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