Arrays, symbols, and realms
On Twitter, Allen Wirfs-Brock asked folks if they knew what Array.isArray(obj)
did, and the results suggested… no they don't. For what it's worth, I also got the answer wrong.
Type-checking arrays
function foo(obj) {
// …
}
Let's say we wanted to do something specific if obj
is an array. JSON.stringify
is an example of this, it outputs arrays differently to other objects.
We could do:
if (obj.constructor == Array) // …
But that's false for things that extend arrays:
class SpecialArray extends Array {}
const specialArray = new SpecialArray();
console.log(specialArray.constructor === Array); // false
console.log(specialArray.constructor === SpecialArray); // true
If you want to catch subclasses, there's instanceof
:
console.log(specialArray instanceof Array); // true
console.log(specialArray instanceof SpecialArray); // true
But things get more complicated when you introduce multiple realms:
Multiple realms
A realm contains the JavaScript global object, which self
refers to. So, it can be said that code running in a worker is in a different realm to code running in the page. The same is true between iframes, but same-origin iframes also share an ECMAScript 'agent', meaning objects can… (and please read the next bit in a 70s sci-fi voiceover) travel across realms.
Seriously, look:
<iframe srcdoc="<script>var arr = [];</script>"></iframe>
<script>
const iframe = document.querySelector('iframe');
const arr = iframe.contentWindow.arr;
console.log(arr.constructor === Array); // false
console.log(arr.constructor instanceof Array); // false
</script>
Both of those are false because:
console.log(Array === iframe.contentWindow.Array); // false
…the iframe has its own array constructor, which is different to the one in the parent page.
Enter Array.isArray
console.log(Array.isArray(arr)); // true
Array.isArray
will return true for arrays, even if they were created in another realm. (You're still reading that in the 70s voice over right?) It'll also return true for subclasses of Array
, from any realm. This is what JSON.stringify
uses internally.
But, as Allen revealed, that doesn't mean arr
has array methods. Some, or even all of the methods would have been set to undefined, or the array could have had its entire prototype ripped out:
const noProtoArray = [];
Object.setPrototypeOf(noProtoArray, null);
console.log(noProtoArray.map); // undefined
console.log(noProtoArray instanceof Array); // false
console.log(Array.isArray(noProtoArray)); // true
That's what I got wrong in Allen's poll, I picked "it has Array methods", the least-picked answer. So, yeah, feeling pretty hipster right now.
Anyway, if you really want to defend against the above, you can apply array methods from the array prototype:
if (Array.isArray(noProtoArray)) {
const mappedArray = Array.prototype.map.call(noProtoArray, callback);
// …
}
Symbols and realms
Take a look at this:
<iframe srcdoc="<script>var arr = [1, 2, 3];</script>"></iframe>
<script>
const iframe = document.querySelector('iframe');
const arr = iframe.contentWindow.arr;
for (const item of arr) {
console.log(item);
}
</script>
The above logs 1, 2, 3. Pretty unspectacular, but for-of loops work by calling arr[Symbol.iterator]
, and this is somehow working across realms. Here's how:
const iframe = document.querySelector('iframe');
const iframeWindow = iframe.contentWindow;
console.log(Symbol === iframeWindow.Symbol); // false
console.log(Symbol.iterator === iframeWindow.Symbol.iterator); // true
While each realm has its own instance of Symbol
, Symbol.iterator
is the same across realms.
To steal a line from Keith Cirkel, symbols are simultaneously the most unique and least unique thing in JavaScript.
The most unique
const symbolOne = Symbol('foo');
const symbolTwo = Symbol('foo');
console.log(symbolOne === symbolTwo); // false
const obj = {};
obj[symbolOne] = 'hello';
console.log(obj[symbolTwo]); // undefined
console.log(obj[symbolOne]); // 'hello'
The string you pass to the Symbol
function is just a description. The symbols are unique, even within the same realm.
The least unique
const symbolOne = Symbol.for('foo');
const symbolTwo = Symbol.for('foo');
console.log(symbolOne === symbolTwo); // true
const obj = {};
obj[symbolOne] = 'hello';
console.log(obj[symbolTwo]); // 'hello'
Symbol.for(str)
creates a symbol that's as unique as the string you pass it. The interesting bit is it's the same across realms:
const iframe = document.querySelector('iframe');
const iframeWindow = iframe.contentWindow;
console.log(Symbol.for('foo') === iframeWindow.Symbol.for('foo')); // true
And this is roughly how Symbol.iterator
works.
Creating our own 'is' function
What if we wanted to create our own 'is' function that worked across realms? Well, symbols allow us to do this:
const typeSymbol = Symbol.for('whatever-type-symbol');
class Whatever {
static isWhatever(obj) {
return obj && Boolean(obj[typeSymbol]);
}
constructor() {
this[typeSymbol] = true;
}
}
const whatever = new Whatever();
Whatever.isWhatever(whatever); // true
This works, even if the instance is from another realm, even if it's a subclass, and even if it has its prototype removed.
The only slight issue, is you need to cross your fingers and hope your symbol name is unique across all the code. If someone else creates their own Symbol.for('whatever-type-symbol')
and uses it to mean something else, isWhatever
could return false positives.