Recently an old playing ES5 bug was raised on Twitter, and as the poll shown, sometimes results can be very fun 🙂
Let’s talk about References.
Reference type
We’ve got familiar with the Reference specification type before, there are also very good explanations around the internets. However, let’s briefly recall when this type comes into play, and how confusing it can be in some cases (luckily such cases shouldn’t appear in practice that often).
A reference is usually involved when we refer to an identifier:
'use strict'; var foo = 10; // Reference `foo` foo;
As a result of evaluation of this expression, which happens during the process called identifier resolution, we get the resolved reference:
var fooReference = { base: global, referencedName: 'foo', strict: true, };
Basically, it’s a “boxed” value, so we can change it if pass the reference to some function:
fooReference.base[fooReference.referencedName] = 20; console.log(foo); // 20
Unresolved reference
In case if the base object doesn’t have such property, the base
component in returned as undefined:
'use strict'; bar;
The reference for bar
identifier is called an unresolved reference:
var barReference = { base: undefined, referencedName: 'bar', strict: true, };
Notice, the strict
component of a reference determines whether the reference was resolved in a strict code, i.e. the code which is evaluated in the strict mode.
Strict mode and unresolved references
From the strict mode analysis we know that assignment to an undeclared variable throws a ReferenceError
. So in our case, being in the strict mode, and trying to assign to bar
, results to an error:
bar = 5; // ReferenceError: "bar" is not defined
Practical implication for this is of course to protect from accidental assignments, and pollution of the global scope which such assignments may cause. In a non-strict (“sloppy”) mode, it’s possible:
function foo() { bar = 10; } foo(); console.log(bar); // oops, 10, escaped to global
The this
value as the global object
We also know that this value in the global context refers to the global object, and that we can access global variables as properties of the global object:
'use strict'; var foo = 10; console.log(this.foo); // 10
var
keyword. The let
, const
, and classes do not create properties.
var foo = 10; console.log(this.foo); // 10 let bar = 20; console.log(this.bar); // undefined
This is due the Global Environment Record in ES2015 is split into two parts: Object Environment Record (which is returned as this
value in the global context), and Declarative Environment Record. Only functions and variables created with var
affect the Object Environment Record.
And first related thing to it, is while not being able to assign to an undeclared identifier, we can normally assign to a new non-existing property of the global object:
'use strict'; this.baz = 10; console.log(baz); // OK, 10 // Can normally reassign baz = 20; console.log(baz); // 20
One interesting subtle part involved with strict mode and unresolved references, is a combination in one statement of these two features: assignment to an “undeclared” identifier, after assignment to a new property:
'use strict'; undeclared = (this.undeclared = 10, console.log(undeclared), 10);
What’s the result of this assignment? Is undeclared
now 10
? By logic which we have seen above, yes, it should be 10
. However, ES spec had a “bug” since ES5 (which likely was considered a feature, and wasn’t fixed in ES6), and this example actually throws ReferenceError
. Let’s trace what’s happening there.
The algorithm is as follows:
First we evaluate LHS (left-hand side), which is… an unresolved reference, since undeclared
hasn’t been declared yet!
var undeclaredReferecne = { base: undefined, referencedName: 'undeclared', strict: true, };
Next we evaluate the RHS, which is a sequence expression wrapped into the grouping operator: it evaluates each sub-expression, and returns final as the result. So first sub-expression basically normally defines a global property undeclared
with the value 10
(which as we have seen above should be accessible after it):
this.undeclared = 10;
The second sub-expression correctly outputs its value:
console.log(undeclared); // 10
As we see, we again resolve the undeclared
identifier, and at this time it’s already returned as resolved:
var undeclaredReferecne = { base: global, referencedName: 'undeclared', strict: true, };
The last sub-expression just returns 10
as the final result of the group expression, and this result should be assigned now to the first reference (the LHS) we resolved. And according to the 6.2.3.2, step 5.a.i, we should throw ReferenceError
, since we already got an unresolved reference on LHS.
At first glance, this doesn’t make much sense, and we could say it was an editorial error in the spec. However, it is still in the latest ES2015 (aka ES6 spec), and a correct behavior is an implementation should throw the ReferenceError
here. A rationale for this, is to avoid possible side effects (like in this specific case), which may appear after evaluating first RHS. So the spec decided to standardize it as “always left to right” evaluation order, and it’s been that way since ES1.
In ES2015 though, “left-to-right” order relates only to a simple assignment. In case of destructuring assignment, RHS is evaluated first:
'use strict'; var {x} = (this.x = 1, {x}); console.log(x); // 1
Support in implementations
Note, at the moment seems none of the implementations handle simple assignment case per spec.
The implementations though, as was pointed out, still evaluate correctly LHS first. This can be shown on the following example, when console.log(1)
is executed first:
var foo = {}; // LHS // RHS (console.log(1), foo).prop = (console.log(2), 10); console.log(foo); // {prop: 10}
However, in case of our initial assignment example, they seem cause this side effect, when the reference becomes resolved (even though LHS was determined as unresolved first, its state might be updated during evaluation of the RHS), and the global variable becomes already defined.
Have fun with ES and implementing of programming languages in general!
Ok, maybe we just have different behaviors due to different versions of Node (I’m on 5.4), or maybe there is a simpler explanation, so want to clarify what I meant in Twitter.
From what I see, Node.js does not implement correct behavior. One of the possible reasons why we’re making different conclusions is that you see error
ReferenceError: undeclared is not defined
and treat that as correct implementation of mentioned issue.However, if you look at the column where the error happens, you can see that it’s unrelated:
What happens here instead is that at the moment of
console.log(undeclared)
is still not declared, and due to fail mode such reference cannot be resolved. Why does that happen? Because in Node.js code is not executed in the global context, but instead in the context of module itself. So at the moment of callingthis.undeclared = 10
, in browserthis
would refer to global object, but in Node it refers toexports
instead, so you’re basically assigningexports.undeclared = 10
and next expressionconsole.log(undeclared)
fails because global variableundeclared
still doesn’t exist.Instead, if you change code to
then global binding will be created through property on real global object, thus reflecting the browser behavior, and in that case you get
10
in the output, so now it reflects the initially discussed bug.@Ingvar Stepanyan, oh, you’re actually right! I completely forgot that
this
value is Node is usually refers to the evaluating module’s exports, not to the global object. So right, if to useglobal
, then it’s incorrectly makesundeclared
available (that basically reflects behavior of Chrome, since V8 is used there too). Thanks, I edited!Block-scoped variables declared in global code would be bindings of a intermediate scope before the global scope, so
this.foo
here will beundefined
.@ziyunfei, good catch! True, the Global Environment Record is split on two sub-environments, Object (which is returned as
this
value in the global context) and Declarative one. Andlet
,const
, andclass
do not affect the object environment. Thanks, I edited tovar
!hi,I had a question,Could you explain for me:
why
a.x
outputundefined
andb.x
output{n:2}
@bird, good question! That’s because assignment is evaluated left-to-right.
In the
a.x = a = {n: 2};
we first evaluatea.x
. It gives us the original object{n: 1}
whicha
, andb
point to (1).Then we evaluate
a = {n: 2}
, which is also an assignment, and also has LHS, and RHS. So again left-to-right, we rebind variablea
to the new object{n: 2}
.Then we assign this value to the (1), which is
x
property on the original object. Notice, at this point the only reference left to the original object isb
variable, anda
is bound to the new object, which doesn’t havex
property anymore.