Blocks As Scopes
While functions are the most common unit of scope, and certainly the most wide-spread of the design approaches in the majority of JS in circulation, other units of scope are possible, and the usage of these other scope units can lead to even better, cleaner to maintain code.
Many languages other than JavaScript support Block Scope, and so developers from those languages are accustomed to the mindset, whereas those who’ve primarily only worked in JavaScript may find the concept slightly foreign.
But even if you’ve never written a single line of code in block-scoped fashion, you are still probably familiar with this extremely common idiom in JavaScript:
for (var i=0; i<10; i++) {
console.log( i );
}
We declare the variable i
directly inside the for-loop head, most likely because our intent is to use i
only within the context of that for-loop, and essentially ignore the fact that the variable actually scopes itself to the enclosing scope (function or global).
That’s what block-scoping is all about. Declaring variables as close as possible, as local as possible, to where they will be used. Another example:
var foo = true;
if (foo) {
var bar = foo * 2;
bar = something( bar );
console.log( bar );
}
We are using a bar
variable only in the context of the if-statement, so it makes a kind of sense that we would declare it inside the if-block. However, where we declare variables is not relevant when using var
, because they will always belong to the enclosing scope. This snippet is essentially “fake” block-scoping, for stylistic reasons, and relying on self-enforcement not to accidentally use bar
in another place in that scope.
Block scope is a tool to extend the earlier “Principle of Least Privilege Exposure” [^note-leastprivilege] from hiding information in functions to hiding information in blocks of our code.
Consider the for-loop example again:
for (var i=0; i<10; i++) {
console.log( i );
}
Why pollute the entire scope of a function with the i
variable that is only going to be (or only should be, at least) used for the for-loop?
But more importantly, developers may prefer to check themselves against accidentally (re)using variables outside of their intended purpose, such as being issued an error about an unknown variable if you try to use it in the wrong place. Block-scoping (if it were possible) for the i
variable would make i
available only for the for-loop, causing an error if i
is accessed elsewhere in the function. This helps ensure variables are not re-used in confusing or hard-to-maintain ways.
But, the sad reality is that, on the surface, JavaScript has no facility for block scope.
That is, until you dig a little further.
with
We learned about with
in Chapter 2. While it is a frowned upon construct, it is an example of (a form of) block scope, in that the scope that is created from the object only exists for the lifetime of that with
statement, and not in the enclosing scope.
try/catch
It’s a very little known fact that JavaScript in ES3 specified the variable declaration in the catch
clause of a try/catch
to be block-scoped to the catch
block.
For instance:
try {
undefined(); // illegal operation to force an exception!
}
catch (err) {
console.log( err ); // works!
}
console.log( err ); // ReferenceError: `err` not found
As you can see, err
exists only in the catch
clause, and throws an error when you try to reference it elsewhere.
Note: While this behavior has been specified and true of practically all standard JS environments (except perhaps old IE), many linters seem to still complain if you have two or more catch
clauses in the same scope which each declare their error variable with the same identifier name. This is not actually a re-definition, since the variables are safely block-scoped, but the linters still seem to, annoyingly, complain about this fact.
To avoid these unnecessary warnings, some devs will name their catch
variables err1
, err2
, etc. Other devs will simply turn off the linting check for duplicate variable names.
The block-scoping nature of catch
may seem like a useless academic fact, but see Appendix B for more information on just how useful it might be.
let
Thus far, we’ve seen that JavaScript only has some strange niche behaviors which expose block scope functionality. If that were all we had, and it was for many, many years, then block scoping would not be terribly useful to the JavaScript developer.
Fortunately, ES6 changes that, and introduces a new keyword let
which sits alongside var
as another way to declare variables.
The let
keyword attaches the variable declaration to the scope of whatever block (commonly a { .. }
pair) it’s contained in. In other words, let
implicitly hijacks any block’s scope for its variable declaration.
var foo = true;
if (foo) {
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
console.log( bar ); // ReferenceError
Using let
to attach a variable to an existing block is somewhat implicit. It can confuse you if you’re not paying close attention to which blocks have variables scoped to them, and are in the habit of moving blocks around, wrapping them in other blocks, etc., as you develop and evolve code.
Creating explicit blocks for block-scoping can address some of these concerns, making it more obvious where variables are attached and not. Usually, explicit code is preferable over implicit or subtle code. This explicit block-scoping style is easy to achieve, and fits more naturally with how block-scoping works in other languages:
var foo = true;
if (foo) {
{ // <-- explicit block
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
}
console.log( bar ); // ReferenceError
We can create an arbitrary block for let
to bind to by simply including a { .. }
pair anywhere a statement is valid grammar. In this case, we’ve made an explicit block inside the if-statement, which may be easier as a whole block to move around later in refactoring, without affecting the position and semantics of the enclosing if-statement.
Note: For another way to express explicit block scopes, see Appendix B.
In Chapter 4, we will address hoisting, which talks about declarations being taken as existing for the entire scope in which they occur.
However, declarations made with let
will not hoist to the entire scope of the block they appear in. Such declarations will not observably “exist” in the block until the declaration statement.
{
console.log( bar ); // ReferenceError!
let bar = 2;
}
Garbage Collection
Another reason block-scoping is useful relates to closures and garbage collection to reclaim memory. We’ll briefly illustrate here, but the closure mechanism is explained in detail in Chapter 5.
Consider:
function process(data) {
// do something interesting
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );
The click
function click handler callback doesn’t need the someReallyBigData
variable at all. That means, theoretically, after process(..)
runs, the big memory-heavy data structure could be garbage collected. However, it’s quite likely (though implementation dependent) that the JS engine will still have to keep the structure around, since the click
function has a closure over the entire scope.
Block-scoping can address this concern, making it clearer to the engine that it does not need to keep someReallyBigData
around:
function process(data) {
// do something interesting
}
// anything declared inside this block can go away after!
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );
Declaring explicit blocks for variables to locally bind to is a powerful tool that you can add to your code toolbox.
let
Loops
A particular case where let
shines is in the for-loop case as we discussed previously.
for (let i=0; i<10; i++) {
console.log( i );
}
console.log( i ); // ReferenceError
Not only does let
in the for-loop header bind the i
to the for-loop body, but in fact, it re-binds it to each iteration of the loop, making sure to re-assign it the value from the end of the previous loop iteration.
Here’s another way of illustrating the per-iteration binding behavior that occurs:
{
let j;
for (j=0; j<10; j++) {
let i = j; // re-bound for each iteration!
console.log( i );
}
}
The reason why this per-iteration binding is interesting will become clear in Chapter 5 when we discuss closures.
Because let
declarations attach to arbitrary blocks rather than to the enclosing function’s scope (or global), there can be gotchas where existing code has a hidden reliance on function-scoped var
declarations, and replacing the var
with let
may require additional care when refactoring code.
Consider:
var foo = true, baz = 10;
if (foo) {
var bar = 3;
if (baz > bar) {
console.log( baz );
}
// ...
}
This code is fairly easily re-factored as:
var foo = true, baz = 10;
if (foo) {
var bar = 3;
// ...
}
if (baz > bar) {
console.log( baz );
}
But, be careful of such changes when using block-scoped variables:
var foo = true, baz = 10;
if (foo) {
let bar = 3;
if (baz > bar) { // <-- don't forget `bar` when moving!
console.log( baz );
}
}
See Appendix B for an alternate (more explicit) style of block-scoping which may provide easier to maintain/refactor code that’s more robust to these scenarios.
const
In addition to let
, ES6 introduces const
, which also creates a block-scoped variable, but whose value is fixed (constant). Any attempt to change that value at a later time results in an error.
var foo = true;
if (foo) {
var a = 2;
const b = 3; // block-scoped to the containing `if`
a = 3; // just fine!
b = 4; // error!
}
console.log( a ); // 3
console.log( b ); // ReferenceError!