Block-Scoped Declarations

You’re probably aware that the fundamental unit of variable scoping in JavaScript has always been the function. If you needed to create a block of scope, the most prevalent way to do so other than a regular function declaration was the immediately invoked function expression (IIFE). For example:

  1. var a = 2;
  2. (function IIFE(){
  3. var a = 3;
  4. console.log( a ); // 3
  5. })();
  6. console.log( a ); // 2

let Declarations

However, we can now create declarations that are bound to any block, called (unsurprisingly) block scoping. This means all we need is a pair of { .. } to create a scope. Instead of using var, which always declares variables attached to the enclosing function (or global, if top level) scope, use let:

  1. var a = 2;
  2. {
  3. let a = 3;
  4. console.log( a ); // 3
  5. }
  6. console.log( a ); // 2

It’s not very common or idiomatic thus far in JS to use a standalone { .. } block, but it’s always been valid. And developers from other languages that have block scoping will readily recognize that pattern.

I believe this is the best way to create block-scoped variables, with a dedicated { .. } block. Moreover, you should always put the let declaration(s) at the very top of that block. If you have more than one to declare, I’d recommend using just one let.

Stylistically, I even prefer to put the let on the same line as the opening {, to make it clearer that this block is only for the purpose of declaring the scope for those variables.

  1. { let a = 2, b, c;
  2. // ..
  3. }

Now, that’s going to look strange and it’s not likely going to match the recommendations given in most other ES6 literature. But I have reasons for my madness.

There’s another experimental (not standardized) form of the let declaration called the let-block, which looks like:

  1. let (a = 2, b, c) {
  2. // ..
  3. }

That form is what I call explicit block scoping, whereas the let .. declaration form that mirrors var is more implicit, as it kind of hijacks whatever { .. } pair it’s found in. Generally developers find explicit mechanisms a bit more preferable than implicit mechanisms, and I claim this is one of those cases.

If you compare the previous two snippet forms, they’re very similar, and in my opinion both qualify stylistically as explicit block scoping. Unfortunately, the let (..) { .. } form, the most explicit of the options, was not adopted in ES6. That may be revisited post-ES6, but for now the former option is our best bet, I think.

To reinforce the implicit nature of let .. declarations, consider these usages:

  1. let a = 2;
  2. if (a > 1) {
  3. let b = a * 3;
  4. console.log( b ); // 6
  5. for (let i = a; i <= b; i++) {
  6. let j = i + 10;
  7. console.log( j );
  8. }
  9. // 12 13 14 15 16
  10. let c = a + b;
  11. console.log( c ); // 8
  12. }

Quick quiz without looking back at that snippet: which variable(s) exist only inside the if statement, and which variable(s) exist only inside the for loop?

The answers: the if statement contains b and c block-scoped variables, and the for loop contains i and j block-scoped variables.

Did you have to think about it for a moment? Does it surprise you that i isn’t added to the enclosing if statement scope? That mental pause and questioning — I call it a “mental tax” — comes from the fact that this let mechanism is not only new to us, but it’s also implicit.

There’s also hazard in the let c = .. declaration appearing so far down in the scope. Unlike traditional var-declared variables, which are attached to the entire enclosing function scope regardless of where they appear, let declarations attach to the block scope but are not initialized until they appear in the block.

Accessing a let-declared variable earlier than its let .. declaration/initialization causes an error, whereas with var declarations the ordering doesn’t matter (except stylistically).

Consider:

  1. {
  2. console.log( a ); // undefined
  3. console.log( b ); // ReferenceError!
  4. var a;
  5. let b;
  6. }

Warning: This ReferenceError from accessing too-early let-declared references is technically called a Temporal Dead Zone (TDZ) error — you’re accessing a variable that’s been declared but not yet initialized. This will not be the only time we see TDZ errors — they crop up in several places in ES6. Also, note that “initialized” doesn’t require explicitly assigning a value in your code, as let b; is totally valid. A variable that’s not given an assignment at declaration time is assumed to have been assigned the undefined value, so let b; is the same as let b = undefined;. Explicit assignment or not, you cannot access b until the let b statement is run.

One last gotcha: typeof behaves differently with TDZ variables than it does with undeclared (or declared!) variables. For example:

  1. {
  2. // `a` is not declared
  3. if (typeof a === "undefined") {
  4. console.log( "cool" );
  5. }
  6. // `b` is declared, but in its TDZ
  7. if (typeof b === "undefined") { // ReferenceError!
  8. // ..
  9. }
  10. // ..
  11. let b;
  12. }

The a is not declared, so typeof is the only safe way to check for its existence or not. But typeof b throws the TDZ error because farther down in the code there happens to be a let b declaration. Oops.

Now it should be clearer why I insist that let declarations should all be at the top of their scope. That totally avoids the accidental errors of accessing too early. It also makes it more explicit when you look at the start of a block, any block, what variables it contains.

Your blocks (if statements, while loops, etc.) don’t have to share their original behavior with scoping behavior.

This explicitness on your part, which is up to you to maintain with discipline, will save you lots of refactor headaches and footguns down the line.

Note: For more information on let and block scoping, see Chapter 3 of the Scope & Closures title of this series.

let + for

The only exception I’d make to the preference for the explicit form of let declaration blocking is a let that appears in the header of a for loop. The reason may seem nuanced, but I believe it to be one of the more important ES6 features.

Consider:

  1. var funcs = [];
  2. for (let i = 0; i < 5; i++) {
  3. funcs.push( function(){
  4. console.log( i );
  5. } );
  6. }
  7. funcs[3](); // 3

The let i in the for header declares an i not just for the for loop itself, but it redeclares a new i for each iteration of the loop. That means that closures created inside the loop iteration close over those per-iteration variables the way you’d expect.

If you tried that same snippet but with var i in the for loop header, you’d get 5 instead of 3, because there’d only be one i in the outer scope that was closed over, instead of a new i for each iteration’s function to close over.

You could also have accomplished the same thing slightly more verbosely:

  1. var funcs = [];
  2. for (var i = 0; i < 5; i++) {
  3. let j = i;
  4. funcs.push( function(){
  5. console.log( j );
  6. } );
  7. }
  8. funcs[3](); // 3

Here, we forcibly create a new j for each iteration, and then the closure works the same way. I prefer the former approach; that extra special capability is why I endorse the for (let .. ) .. form. It could be argued it’s somewhat more implicit, but it’s explicit enough, and useful enough, for my tastes.

let also works the same way with for..in and for..of loops (see “for..of Loops”).

const Declarations

There’s one other form of block-scoped declaration to consider: the const, which creates constants.

What exactly is a constant? It’s a variable that’s read-only after its initial value is set. Consider:

  1. {
  2. const a = 2;
  3. console.log( a ); // 2
  4. a = 3; // TypeError!
  5. }

You are not allowed to change the value the variable holds once it’s been set, at declaration time. A const declaration must have an explicit initialization. If you wanted a constant with the undefined value, you’d have to declare const a = undefined to get it.

Constants are not a restriction on the value itself, but on the variable’s assignment of that value. In other words, the value is not frozen or immutable because of const, just the assignment of it. If the value is complex, such as an object or array, the contents of the value can still be modified:

  1. {
  2. const a = [1,2,3];
  3. a.push( 4 );
  4. console.log( a ); // [1,2,3,4]
  5. a = 42; // TypeError!
  6. }

The a variable doesn’t actually hold a constant array; rather, it holds a constant reference to the array. The array itself is freely mutable.

Warning: Assigning an object or array as a constant means that value will not be able to be garbage collected until that constant’s lexical scope goes away, as the reference to the value can never be unset. That may be desirable, but be careful if it’s not your intent!

Essentially, const declarations enforce what we’ve stylistically signaled with our code for years, where we declared a variable name of all uppercase letters and assigned it some literal value that we took care never to change. There’s no enforcement on a var assignment, but there is now with a const assignment, which can help you catch unintended changes.

const can be used with variable declarations of for, for..in, and for..of loops (see “for..of Loops”). However, an error will be thrown if there’s any attempt to reassign, such as the typical i++ clause of a for loop.

const Or Not

There’s some rumored assumptions that a const could be more optimizable by the JS engine in certain scenarios than a let or var would be. Theoretically, the engine more easily knows the variable’s value/type will never change, so it can eliminate some possible tracking.

Whether const really helps here or this is just our own fantasies and intuitions, the much more important decision to make is if you intend constant behavior or not. Remember: one of the most important roles for source code is to communicate clearly, not only to you, but your future self and other code collaborators, what your intent is.

Some developers prefer to start out every variable declaration as a const and then relax a declaration back to a let if it becomes necessary for its value to change in the code. This is an interesting perspective, but it’s not clear that it genuinely improves the readability or reason-ability of code.

It’s not really a protection, as many believe, because any later developer who wants to change a value of a const can just blindly change const to let on the declaration. At best, it protects accidental change. But again, other than our intuitions and sensibilities, there doesn’t appear to be objective and clear measure of what constitutes “accidents” or prevention thereof. Similar mindsets exist around type enforcement.

My advice: to avoid potentially confusing code, only use const for variables that you’re intentionally and obviously signaling will not change. In other words, don’t rely on const for code behavior, but instead use it as a tool for signaling intent, when intent can be signaled clearly.

Block-scoped Functions

Starting with ES6, function declarations that occur inside of blocks are now specified to be scoped to that block. Prior to ES6, the specification did not call for this, but many implementations did it anyway. So now the specification meets reality.

Consider:

  1. {
  2. foo(); // works!
  3. function foo() {
  4. // ..
  5. }
  6. }
  7. foo(); // ReferenceError

The foo() function is declared inside the { .. } block, and as of ES6 is block-scoped there. So it’s not available outside that block. But also note that it is “hoisted” within the block, as opposed to let declarations, which suffer the TDZ error trap mentioned earlier.

Block-scoping of function declarations could be a problem if you’ve ever written code like this before, and relied on the old legacy non-block-scoped behavior:

  1. if (something) {
  2. function foo() {
  3. console.log( "1" );
  4. }
  5. }
  6. else {
  7. function foo() {
  8. console.log( "2" );
  9. }
  10. }
  11. foo(); // ??

In pre-ES6 environments, foo() would print "2" regardless of the value of something, because both function declarations were hoisted out of the blocks, and the second one always wins.

In ES6, that last line throws a ReferenceError.