Some of you may recognize the problem in the title. It's a bit of a famous sample of wtfJS, explained very well by Brandon Morelli in 2018.
The code sample is as follows:
if (a == 1 && a == 2 && a == 3) {
console.log("What?!");
}
// logs: What?!
Why does it work? Well the trick is in realizing that a
here is not a primitive. It's an object with a getter - pretending to be a primitive.
So what happens when we even try to compare an object and a primitive? If we take a look at the spec it says (by rules 8 & 9) that we try to coerce the object into a primitive. How? Via ToPrimitive
, a function defined in the spec as well.
In short, it tries to coerce the object into a number. If that doesn't work, it tries to coerce it into a string. Let's try to coerce an object to a string and number.
const num = Number({});
console.log(num); // NaN
const str = String({});
console.log(str); // [object Object]
Okay, so neither of those are helpful. How exactly is it coercing them?
According to the spec, it's calling .valueOf
to get the number and .toString
to get the string. If .valueOf
returns an object, it moves on to .toString
. If .toString
doesn't return a primitive, it will actually throw an error: Uncaught TypeError: Cannot convert object to primitive value
.
We can override them ourselves like so:
const a = {
valueOf() {
return 55;
},
toString() {
return 100;
}
};
if (55 == a) console.log("we got valueOf()!");
if (100 == a) console.log("we got toString()!");
// logs: we got valueOf()!
// returning an object, so it will be skipped
a.valueOf = function() { return {} };
if (55 == a) console.log("we got valueOf()!");
if (100 == a) console.log("we got toString()!");
// logs: we got toString()!
You see, we don't actually have to return a string or number in either.
So how do we use this to solve our problem? We make one of the getters return a value and increment it.
const a = {
val: 0,
valueOf() {
this.val++;
console.log("value incremented!");
return this.val;
}
};
if (a == 1 && a == 2 && a == 3) {
console.log("We got it!");
}
// logs:
// value incremented!
// value incremented!
// value incremented!
// We got it!
We can do something similarly with the Proxy
class, but taking advantage of the same concept.
const a = new Proxy({ value: 1 }, {
get(obj, prop) {
if (prop !== 'valueOf') return obj[prop];
return () => obj.value++;
}
})
I won't go too into Proxy
in this article, as Keith Cirkel made a much better article on the subject over here.
In essence, we are defining a new object with a getter "trap" that returns the current value property and increments it if its .valueOf()
method is called. This is just a fancier way of doing something that we did much simpler just before.
Regardless, is this impossible using strict equality? What if we were presented with the same example, but with triple equals?
Strict Equality
Well actually, it's possible. But first, we have to nail down a few fundamentals.
The first being the window
object. Any property on this object is automatically given to us as if it were defined in some global scope. As such, window.parseInt
is the same as just parseInt
, window.alert
is the same as just alert
, and so on.
We can also define our own properties and seemingly create variables dynamically.
function makeVariables() {
window.foo = 55;
window.bar = "hello";
}
makeVariables()
if (foo) console.log(foo);
if (bar) console.log(bar);
if (baz) console.log(baz);
// logs:
// 55
// "hello"
// Uncaught ReferenceError: baz is not defined
Side note - this is a bad idea. Don't do this. But we're going to need this for our own example.
Next, we need to go over Object.defineProperty
. This function lets us define properties on objects with unique qualities. It feels new, but it actually works on IE9.
This cool method lets us make a property really constant, so people don't change it. It also lets us define a custom getter method! Things are starting to feel a bit familiar!
const myObj = {}
Object.defineProperty(myObj, 'val', {
get() {
return Math.random();
}
})
console.log(myObj.val);
console.log(myObj.val);
console.log(myObj.val);
// logs:
// 0.6492479252057994
// 0.6033118630593071
// 0.6033118630593071
Why is this better than the previous method? Well this time we don't have to rely on coercion!
Let's combine the 2 things we just discussed to finalize our second example:
let value = 0;
Object.defineProperty(window, 'a', {
get() {
value++;
console.log("value incremented!");
return value;
}
})
if (a === 1 && a === 2 && a === 3) {
console.log("We got it!");
}
// logs:
// value incremented!
// value incremented!
// value incremented!
// We got it!
Nice! Now we have it working with strict equality!
We unfortunately can't define a variable in the object itself (and then access it in the getter), but if we really don't want to pollute the scope, we can use closures and IIFEs in a really tricky way (credit goes to P35 from the SpeakJS discord server).
Object.defineProperty(window, 'a', (function(){
let value = 0;
return {
get() {
value++;
console.log("value incremented!");
return value;
}
}
})());
but this obviously is a pretty messy example.
What about Proxy
? Could we use it here? Unfortunately, Proxy
will not work with the window
object, so it doesn't help us in this instance.
Wrap Up
So when is this useful? Hardly ever.
Well, there are some occasions. Have you ever encountered a really weird error when using a JS framework? Something like Uncaught TypeError: Invalid property descriptor. Cannot both specify accessors and a value or writable attribute
?
Your framework is probably using Proxies and getter methods under the hood. They're useful, but only when things get complicated and you want to hide that underlying complexity.
Top comments (3)
There is no reason to use an IIFE, certainly not one that returns anything.
Since we are using a block scoped variable we can just wrap it in curly braces:
Yes, that's true!
I was thinking more along the lines of having the scope reside entirely within
Object.defineProperty
where I don't believe a block scope would actually work, which is why I used an IIFE.But this is completely valid, too!
Haha, that would have been some real weirdness. It still works, but you need some code before it.
I do have a different article, the one on wtfjs and coercion, that is just copy/paste though!