Free email newsletter: “ES.next News

2015-09-29

Customizing ES6 via well-known symbols

In ECMAScript 6, the object Symbol has several properties that contain so-called well-known symbols (Symbol.iterator, Symbol.hasInstance, etc.). These let you customize how ES6 treats objects. This blog post explains the details.

Warning

Implementation of the features described here is work in progress. Consult the “ECMAScript 6 compatibility table” for what is supported where (spoiler: not much, in few engines).

Background

This section covers knowledge that is useful for the remainder of this post. Additionally, the following material may be of interest:

  • Chapter “Symbols” in “Exploring ES6”
  • Chapter “Values” (primitive values versus objects, etc.) in “Speaking JavaScript”

Internal properties

The ES6 specification uses internal properties to describe how JavaScript works. These are only known to the spec and not accessible from JavaScript. They may or may not exist in an actual implementation of the language. The names of internal properties are written in double square brackets.

For example: the link between an object and its prototype is the internal property [[Prototype]]. The value of that property cannot be read directly via JavaScript, but you can use Object.getPrototypeOf() to do so.

Overriding inherited properties

If an object obj inherits a property prop that is read-only then you can’t assign to that property:

    let proto = Object.defineProperty({}, 'prop', {
        writable: false,
        configurable: true,
        value: 123,
    });
    let obj = Object.create(proto);
    obj.prop = 456;
        // TypeError: Cannot assign to read-only property

This is similar to how an inherited property works that has a getter, but no setter. It is in line with viewing assignment as changing the value of an inherited property. It does so non-destructively: the original is not modified, but overridden by a newly created own property. Therefore, an inherited read-only property and an inherited setter-less property both prevent changes via assignment. You can, however, force the creation of an own property via Object.defineProperty():

    let proto = Object.defineProperty({}, 'prop', {
        writable: false,
        configurable: true,
        value: 123,
    });
    let obj = Object.create(proto);
    Object.defineProperty(obj, 'prop', {value: 456});
    console.log(obj.prop); // 456

Overview: all well-known symbols in ES6

All well-known symbols in ES6 are keys for properties. If you add a property to an object that has one of those keys, you change how ES6 treats that object. These are all well-known symbols in ES6:

  1. Customizing basic language operations:
    • Symbol.hasInstance (method)
      customizes instanceof.
    • Symbol.toPrimitive (method)
      customizes the coercion of an object to a primitive value.
    • Symbol.toStringTag (string)
      customizes the result returned by Object.prototype.toString().
  2. Iteration:
    • Symbol.iterator (method)
      A method with this key makes an object iterable (its elements can be iterated over language constructs such as the for-of loop and the spread operator (...)). Details: chapter “Iterables and iterators” of “Exploring ES6”.
  3. Forwarding calls from string methods to their parameters:
    • Symbol.match
    • Symbol.replace
    • Symbol.search
    • Symbol.split
  4. Miscellaneous:
    • Symbol.unscopables (Object)
      lets you hide some properties from the with statement.
    • Symbol.species (method)
      configures how built-in methods create objects that are similar to this.
    • Symbol.isConcatSpreadable (boolean)
      configures whether Array.prototype.concat() adds the indexed elements of an object to its result (“spreading”) or the object as a single element.

The following sections have more information on categories 1, 3 and 4.

Customizing basic language operations

Symbol.hasInstance (method)

A method with the key Symbol.hasInstance lets an object C customize the behavior of the instanceof operator. Signature of that method:

    [Symbol.hasInstance](potentialInstance : any)

x instanceof C works as follows in ES6:

  • If C is not an object, throw a TypeError.
  • If the method exists, call C[Symbol.hasInstance](x), coerce the result to boolean and return it.
  • Otherwise, compute and return the result according to the traditional algorithm (C must be callable, C.prototype in the prototype chain of x, etc.).
Uses in the standard library

The only method in the standard library that has this key is:

  • Function.prototype[Symbol.hasInstance]()

This is the implementation of instanceof that all functions (including classes) use by default. Quoting the spec:

This property is non-writable and non-configurable to prevent tampering that could be used to globally expose the target function of a bound function.

The tampering is possible because the traditional instanceof algorithm, OrdinaryHasInstance(), applies instanceof to the target function if it encounters a bound function.

Given that this property is read-only, you can’t use assignment to override it, as mentioned earlier.

Example: checking whether a value is an object

As an example, let’s implement an object ReferenceType whose “instances” are all objects, not just objects that are instances of Object (and therefore have Object.prototype in their prototype chains).

    const ReferenceType = {
        [Symbol.hasInstance](value) {
            return (value !== null
                && (typeof value === 'object'
                    || typeof value === 'function'));
        }
    };
    const obj1 = {};
    console.log(obj1 instanceof Object); // true
    console.log(obj1 instanceof ReferenceType); // true
    
    const obj2 = Object.create(null);
    console.log(obj2 instanceof Object); // false
    console.log(obj2 instanceof ReferenceType); // true

Symbol.toPrimitive (method)

Symbol.toPrimitive lets an object customize how it is coerced (converted automatically) to a primitive value.

Many JavaScript operations coerce values to the types that they need.

  • The multiplication operator (*) coerces its operands to numbers.
  • new Date(year, month, date) coerces its parameters to numbers.
  • parseInt(string , radix) coerces its first parameter to a string.

The following are the most common coercions:

  • Boolean: Coercion returns true for truthy values, false for falsy values. Objects are always truthy (even new Boolean(false)).
  • Number: Coercion converts objects to primitives first. Primitives are then converted to numbers (null0, true1, '123'123, etc.).
  • String: Coercion converts objects to primitives first. Primitives are then converted to strings (null'null', true'true', 123'123', etc.).
  • Object: The coercion wraps primitive values (booleans b via new Boolean(b), numbers n via new Number(n), etc.).

Converting an arbitrary value to a primitive is handled via the spec-internal operation ToPrimitive() which has three modes:

  • Number: the caller needs a number.
  • String: the caller needs a string.
  • Default: the caller needs either a number or a string.

The default mode is only used by:

  • Equality operator (==)
  • Addition operator (+)
  • new Date(value) (exactly one parameter!)

If the value is a primitive then ToPrimitive() is already done. Otherwise, the value is an object obj, which is converted to a promitive as follows:

  • Number mode: Return the result of obj.valueOf() if it is primitive. Otherwise, return the result of obj.toString() if it is primitive. Otherwise, throw a TypeError.
  • String mode: works like Number mode, but toString() is called first, valueOf() second.
  • Default mode: works exactly like Number mode.

This normal algorithm can be overridden by giving an object a method with the following signature:

    [Symbol.toPrimitive](hint : 'default' | 'string' | 'number')

In the standard library, there are two such methods:

  • Symbol.prototype[Symbol.toPrimitive](hint)
    prevents toString() from being called (which throws an exception).

  • Date.prototype[Symbol.toPrimitive](hint)
    This method implements behavior that deviates from the default algorithm. Quoting the specification: “Date objects are unique among built-in ECMAScript object in that they treat 'default' as being equivalent to 'string'. All other built-in ECMAScript objects treat 'default' as being equivalent to 'number'.”

Example

The following code demonstrates how coercion affects the object obj.

    let obj = {
        [Symbol.toPrimitive](hint) {
            switch (hint) {
                case 'number':
                    return 123;
                case 'string':
                    return 'str';
                case 'default':
                    return 'default';
                default:
                    throw new Error();
            }
        }
    };
    console.log(2 * obj); // 246
    console.log(3 + obj); // '3default'
    console.log(obj == 'default'); // true
    console.log(String(obj)); // 'str'

Symbol.toStringTag (string)

In ES5 and earlier, each object had the internal own property [[Class]] whose value hinted at its type. You could not access it directly, but its value was part of the string returned by Object.prototype.toString(), which is why that method was used for type checks, as an alternative to typeof.

In ES6, there is no internal property [[Class]], anymore, and using Object.prototype.toString() for type checks is discouraged. In order to ensure the backwards-compatibility of that method, the public property with the key Symbol.toStringTag was introduced. You could say that it replaces [[Class]].

Object.prototype.toString() now works as follows:

  • Convert this to an object obj.
  • Determine the toString tag tst of obj.
  • Return '[object ' + tst + ']'.
Default toString tags

The default values for various kinds of objects are shown in the following table.

Value toString tag
undefined 'Undefined'
null 'Null'
An Array object 'Array'
A string object 'String'
arguments 'Arguments'
Something callable 'Function'
An error object 'Error'
A boolean object 'Boolean'
A number object 'Number'
A date object 'Date'
A regular expression object 'RegExp'
(Otherwise) 'Object'

Most of the checks in the left column are performed by looking at internal properties. For example, if an object has the internal property [[Call]], it is callable.

The following interaction demonstrates the default toString tags.

    > Object.prototype.toString.call(null)
    '[object Null]'
    > Object.prototype.toString.call([])
    '[object Array]'
    > Object.prototype.toString.call({})
    '[object Object]'
    > Object.prototype.toString.call(Object.create(null))
    '[object Object]'
Overriding the default toString tag

If an object has an (own or inherited) property whose key is Symbol.toStringTag then its value overrides the default toString tag. For example:

    > ({}.toString())
    '[object Object]'
    > ({[Symbol.toStringTag]: 'Foo'}.toString())
    '[object Foo]'

Instances of user-defined classes get the default toString tag (of objects):

    class Foo { }
    console.log(new Foo().toString()); // [object Object]

One option for overriding the default is via a getter:

    class Bar {
        get [Symbol.toStringTag]() {
          return 'Bar';
        }
    }
    console.log(new Bar().toString()); // [object Bar]

In the JavaScript standard library, there are the following custom toString tags. Objects that have no global names are quoted with percent symbols (for example: %TypedArray%).

  • Module-like objects:
    • JSON[Symbol.toStringTag]'JSON'
    • Math[Symbol.toStringTag]'Math'
  • Actual module objects M: M[Symbol.toStringTag]'Module'
  • Built-in classes
    • ArrayBuffer.prototype[Symbol.toStringTag]'ArrayBuffer'
    • DataView.prototype[Symbol.toStringTag]'DataView'
    • Map.prototype[Symbol.toStringTag]'Map'
    • Promise.prototype[Symbol.toStringTag]'Promise'
    • Set.prototype[Symbol.toStringTag]'Set'
    • get %TypedArray%.prototype[Symbol.toStringTag]'Uint8Array' etc.
    • WeakMap.prototype[Symbol.toStringTag]'WeakMap'
    • WeakSet.prototype[Symbol.toStringTag]'WeakSet'
  • Iterators
    • %MapIteratorPrototype%[Symbol.toStringTag]'Map Iterator'
    • %SetIteratorPrototype%[Symbol.toStringTag]'Set Iterator'
    • %StringIteratorPrototype%[Symbol.toStringTag]'String Iterator'
  • Miscellaneous
    • Symbol.prototype[Symbol.toStringTag]'Symbol'
    • Generator.prototype[Symbol.toStringTag]'Generator'
    • GeneratorFunction.prototype[Symbol.toStringTag]'GeneratorFunction'

All of the built-in properties whose keys are Symbol.toStringTag have the following property descriptor:

    {
        writable: false,
        enumerable: false,
        configurable: true,
    }

As mentioned in an earlier section, you can’t use assignment to override those properties, because they are read-only.

Forwarding calls from string methods to their parameters

In ES6, the four string methods that accept regular expression parameters do relatively little. They mainly call methods of their parameters:

  • String.prototype.match(regexp) calls regexp[Symbol.match](this).
  • String.prototype.replace(searchValue, replaceValue) calls searchValue[Symbol.replace](this, replaceValue).
  • String.prototype.search(regexp) calls regexp[Symbol.search](this).
  • String.prototype.split(separator, limit) calls separator[Symbol.split](this, limit).

The parameters don’t have to be regular expressions, anymore. Any objects with appropriate methods will do.

Miscellaneous

Symbol.unscopables (Object)

Symbol.unscopables lets an object hide some properties from the with statement.

The reason for doing so is that it allows TC39 to add new methods to Array.prototype without breaking old code. Note that current code rarely uses with, which is forbidden in strict mode and therefore ES6 modules (which are implicitly in strict mode).

Why would adding methods to Array.prototype break code that uses with (such as the widely deployed Ext JS 4.2.1)? Take a look at the following code. The existence of a property Array.prototype.values breaks foo(), if you call it with an Array:

    function foo(values) {
        with (values) {
            console.log(values.length); // abc (*)
        }
    }
    Array.prototype.values = { length: 'abc' };
    foo([]);

Inside the with statement, all properties of values become local variables, shadowing even values itself. Therefore, if values has a property values then the statement in line * logs values.values.length and not values.length.

Symbol.unscopables is used only once in the standard library:

  • Array.prototype[Symbol.unscopables]
    • Holds an object with the following properties (which are therefore hidden from the with statement): copyWithin, entries, fill, find, findIndex, keys, values

Symbol.species (method)

Symbol.species lets you configure how methods of built-in objects create instances they return. One example is that you can configure what Array.prototype.map() returns. By default, it uses the same constructor that created this to create the return value, but you can override that by setting Array[Symbol.species].

The details are explained in the chapter on classes of “Exploring ES6”.

Symbol.isConcatSpreadable (boolean)

Symbol.isConcatSpreadable lets you configure how Array.prototype.concat() adds an object to its result.

The default for Arrays is to “spread” them, their indexed elements become elements of the result:

    let arr1 = ['c', 'd'];
    ['a', 'b'].concat(arr1, 'e'); // ['a', 'b', 'c', 'd', 'e']

With Symbol.isConcatSpreadable, you can override the default and avoid spreading for Arrays:

    let arr2 = ['c', 'd'];
    arr2[Symbol.isConcatSpreadable] = false;
    ['a', 'b'].concat(arr2, 'e'); // ['a', 'b', ['c','d'], 'e']

For non-Arrays, the default is not to spread. You can use Symbol.isConcatSpreadable to force spreading:

    let obj = {length: 2, 0: 'c', 1: 'd'};
    console.log(['a', 'b'].concat(obj, 'e')); // ['a', 'b', obj, 'e']
    
    obj[Symbol.isConcatSpreadable] = true;
    console.log(['a', 'b'].concat(obj, 'e')); // ['a', 'b', 'c', 'd', 'e']

The default in ES6 is to spread only Array objects. Whether or not something is an Array object is tested via Array.isArray() (or rather, the same operation that that method uses). Whether or not Array.prototype is in the prototype chain makes no difference for that test (which is important, because, in ES5 and earlier, hacks were used to subclass Array and those must continue to work; see blog post “__proto__ in ECMAScript 6”):

    > let arr = [];
    > Array.isArray(arr)
    true
    
    > Object.setPrototypeOf(arr, null);
    > Array.isArray(arr)
    true

The default can be overridden by adding a property whose key is Symbol.isConcatSpreadable to the object itself or to one of its prototypes, and by setting it to either true or false.

No object in the ES6 standard library has a property with the key Symbol.isConcatSpreadable. This mechanism therefore exists purely for browser APIs and user code.

Consequences:

  • Subclasses of Array are spread by default (because their instances are Array objects).
  • A subclass of Array can prevent its instances from being spread by setting a property to false whose key is Symbol.isConcatSpreadable. That property can be a prototype property or an instance property.

  • Other Array-like objects are spread by concat() if property [Symbol.isConcatSpreadable] is true. That would enable one, for example, to turn on spreading for some Array-like DOM collections.

  • Typed Arrays are not spread. They don’t have a method concat(), either.

Symbol.isConcatSpreadable in the ES6 spec
  • In the description of Array.prototype.concat(), you can see that spreading requires an object to be Array-like (property length plus indexed elements).
  • Whether or not to spread an object is determined via the spec operation IsConcatSpreadable(). The last step is the default (equivalent to Array.isArray()) and the property [Symbol.isConcatSpreadable] is retrieved via a normal Get() operation, meaning that it doesn’t matter whether it is own or inherited.

Spelling: Why Symbol.hasInstance and not Symbol.HAS_INSTANCE (etc.)?

The well-known symbols are stored in properties whose names start with lowercase characters and are camel-cased. In a way, these properties are constants and it is customary for constants to have all-caps names (Math.PI etc.). But the reasoning for their spelling is different: Well-known symbols are used instead of normal property keys, which is why their “names” follow the rules for property keys, not the rules for constants.

No comments: