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:
- Customizing basic language operations:
Symbol.hasInstance
(method)
customizesinstanceof
.Symbol.toPrimitive
(method)
customizes the coercion of an object to a primitive value.Symbol.toStringTag
(string)
customizes the result returned byObject.prototype.toString()
.
- Iteration:
Symbol.iterator
(method)
A method with this key makes an object iterable (its elements can be iterated over language constructs such as thefor-of
loop and the spread operator (...
)). Details: chapter “Iterables and iterators” of “Exploring ES6”.
- Forwarding calls from string methods to their parameters:
Symbol.match
Symbol.replace
Symbol.search
Symbol.split
- Miscellaneous:
Symbol.unscopables
(Object)
lets you hide some properties from thewith
statement.Symbol.species
(method)
configures how built-in methods create objects that are similar tothis
.Symbol.isConcatSpreadable
(boolean)
configures whetherArray.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 aTypeError
. - 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 ofx
, 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 (evennew Boolean(false)
). - Number: Coercion converts objects to primitives first. Primitives are then converted to numbers (
null
→0
,true
→1
,'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
vianew Boolean(b)
, numbersn
vianew 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 ofobj.toString()
if it is primitive. Otherwise, throw aTypeError
. - 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)
preventstoString()
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 objectobj
. - Determine the toString tag
tst
ofobj
. - 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)
callsregexp[Symbol.match](this)
.String.prototype.replace(searchValue, replaceValue)
callssearchValue[Symbol.replace](this, replaceValue)
.String.prototype.search(regexp)
callsregexp[Symbol.search](this)
.String.prototype.split(separator, limit)
callsseparator[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
- Holds an object with the following properties (which are therefore hidden from the
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 tofalse
whose key isSymbol.isConcatSpreadable
. That property can be a prototype property or an instance property.Other Array-like objects are spread by
concat()
if property[Symbol.isConcatSpreadable]
istrue
. 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 (propertylength
plus indexed elements). - Whether or not to spread an object is determined via the spec operation
IsConcatSpreadable()
. The last step is the default (equivalent toArray.isArray()
) and the property[Symbol.isConcatSpreadable]
is retrieved via a normalGet()
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.
Comments
Post a Comment