Scoping with Blocks

You should by this point feel fairly comfortable with the merits of creating scopes to limit identifier exposure.

So far, we looked at doing this via function (i.e., IIFE) scope. But let’s now consider using let declarations with nested blocks. In general, any { .. } curly-brace pair which is a statement will act as a block, but not necessarily as a scope.

A block only becomes a scope if necessary, to contain its block-scoped declarations (i.e., let or const). Consider:

  1. {
  2. // not necessarily a scope (yet)
  3. // ..
  4. // now we know the block needs to be a scope
  5. let thisIsNowAScope = true;
  6. for (let i = 0; i < 5; i++) {
  7. // this is also a scope, activated each
  8. // iteration
  9. if (i % 2 == 0) {
  10. // this is just a block, not a scope
  11. console.log(i);
  12. }
  13. }
  14. }
  15. // 0 2 4

Not all { .. } curly-brace pairs create blocks (and thus are eligible to become scopes):

  • Object literals use { .. } curly-brace pairs to delimit their key-value lists, but such object values are not scopes.

  • class uses { .. } curly-braces around its body definition, but this is not a block or scope.

  • A function uses { .. } around its body, but this is not technically a block—it’s a single statement for the function body. It is, however, a (function) scope.

  • The { .. } curly-brace pair on a switch statement (around the set of case clauses) does not define a block/scope.

Other than such non-block examples, a { .. } curly-brace pair can define a block attached to a statement (like an if or for), or stand alone by itself—see the outermost { .. } curly brace pair in the previous snippet. An explicit block of this sort—if it has no declarations, it’s not actually a scope—serves no operational purpose, though it can still be useful as a semantic signal.

Explicit standalone { .. } blocks have always been valid JS syntax, but since they couldn’t be a scope prior to ES6’s let/const, they are quite rare. However, post ES6, they’re starting to catch on a little bit.

In most languages that support block scoping, an explicit block scope is an extremely common pattern for creating a narrow slice of scope for one or a few variables. So following the POLE principle, we should embrace this pattern more widespread in JS as well; use (explicit) block scoping to narrow the exposure of identifiers to the minimum practical.

An explicit block scope can be useful even inside of another block (whether the outer block is a scope or not).

For example:

  1. if (somethingHappened) {
  2. // this is a block, but not a scope
  3. {
  4. // this is both a block and an
  5. // explicit scope
  6. let msg = somethingHappened.message();
  7. notifyOthers(msg);
  8. }
  9. // ..
  10. recoverFromSomething();
  11. }

Here, the { .. } curly-brace pair inside the if statement is an even smaller inner explicit block scope for msg, since that variable is not needed for the entire if block. Most developers would just block-scope msg to the if block and move on. And to be fair, when there’s only a few lines to consider, it’s a toss-up judgement call. But as code grows, these over-exposure issues become more pronounced.

So does it matter enough to add the extra { .. } pair and indentation level? I think you should follow POLE and always (within reason!) define the smallest block for each variable. So I recommend using the extra explicit block scope as shown.

Recall the discussion of TDZ errors from “Uninitialized Variables (TDZ)” (Chapter 5). My suggestion there was: to minimize the risk of TDZ errors with let/const declarations, always put those declarations at the top of their scope.

If you find yourself placing a let declaration in the middle of a scope, first think, “Oh, no! TDZ alert!” If this let declaration isn’t needed in the first half of that block, you should use an inner explicit block scope to further narrow its exposure!

Another example with an explicit block scope:

  1. function getNextMonthStart(dateStr) {
  2. var nextMonth, year;
  3. {
  4. let curMonth;
  5. [ , year, curMonth ] = dateStr.match(
  6. /(\d{4})-(\d{2})-\d{2}/
  7. ) || [];
  8. nextMonth = (Number(curMonth) % 12) + 1;
  9. }
  10. if (nextMonth == 1) {
  11. year++;
  12. }
  13. return `${ year }-${
  14. String(nextMonth).padStart(2,"0")
  15. }-01`;
  16. }
  17. getNextMonthStart("2019-12-25"); // 2020-01-01

Let’s first identify the scopes and their identifiers:

  1. The outer/global scope has one identifier, the function getNextMonthStart(..).

  2. The function scope for getNextMonthStart(..) has three: dateStr (parameter), nextMonth, and year.

  3. The { .. } curly-brace pair defines an inner block scope that includes one variable: curMonth.

So why put curMonth in an explicit block scope instead of just alongside nextMonth and year in the top-level function scope? Because curMonth is only needed for those first two statements; at the function scope level it’s over-exposed.

This example is small, so the hazards of over-exposing curMonth are pretty limited. But the benefits of the POLE principle are best achieved when you adopt the mindset of minimizing scope exposure by default, as a habit. If you follow the principle consistently even in the small cases, it will serve you more as your programs grow.

Let’s now look at an even more substantial example:

  1. function sortNamesByLength(names) {
  2. var buckets = [];
  3. for (let firstName of names) {
  4. if (buckets[firstName.length] == null) {
  5. buckets[firstName.length] = [];
  6. }
  7. buckets[firstName.length].push(firstName);
  8. }
  9. // a block to narrow the scope
  10. {
  11. let sortedNames = [];
  12. for (let bucket of buckets) {
  13. if (bucket) {
  14. // sort each bucket alphanumerically
  15. bucket.sort();
  16. // append the sorted names to our
  17. // running list
  18. sortedNames = [
  19. ...sortedNames,
  20. ...bucket
  21. ];
  22. }
  23. }
  24. return sortedNames;
  25. }
  26. }
  27. sortNamesByLength([
  28. "Sally",
  29. "Suzy",
  30. "Frank",
  31. "John",
  32. "Jennifer",
  33. "Scott"
  34. ]);
  35. // [ "John", "Suzy", "Frank", "Sally",
  36. // "Scott", "Jennifer" ]

There are six identifiers declared across five different scopes. Could all of these variables have existed in the single outer/global scope? Technically, yes, since they’re all uniquely named and thus have no name collisions. But this would be really poor code organization, and would likely lead to both confusion and future bugs.

We split them out into each inner nested scope as appropriate. Each variable is defined at the innermost scope possible for the program to operate as desired.

sortedNames could have been defined in the top-level function scope, but it’s only needed for the second half of this function. To avoid over-exposing that variable in a higher level scope, we again follow POLE and block-scope it in the inner explicit block scope.

var and let

Next, let’s talk about the declaration var buckets. That variable is used across the entire function (except the final return statement). Any variable that is needed across all (or even most) of a function should be declared so that such usage is obvious.

NOTE:
The parameter names isn’t used across the whole function, but there’s no way limit the scope of a parameter, so it behaves as a function-wide declaration regardless.

So why did we use var instead of let to declare the buckets variable? There’s both semantic and technical reasons to choose var here.

Stylistically, var has always, from the earliest days of JS, signaled “variable that belongs to a whole function.” As we asserted in “Lexical Scope” (Chapter 1), var attaches to the nearest enclosing function scope, no matter where it appears. That’s true even if var appears inside a block:

  1. function diff(x,y) {
  2. if (x > y) {
  3. var tmp = x; // `tmp` is function-scoped
  4. x = y;
  5. y = tmp;
  6. }
  7. return y - x;
  8. }

Even though var is inside a block, its declaration is function-scoped (to diff(..)), not block-scoped.

While you can declare var inside a block (and still have it be function-scoped), I would recommend against this approach except in a few specific cases (discussed in Appendix A). Otherwise, var should be reserved for use in the top-level scope of a function.

Why not just use let in that same location? Because var is visually distinct from let and therefore signals clearly, “this variable is function-scoped.” Using let in the top-level scope, especially if not in the first few lines of a function, and when all the other declarations in blocks use let, does not visually draw attention to the difference with the function-scoped declaration.

In other words, I feel var better communicates function-scoped than let does, and let both communicates (and achieves!) block-scoping where var is insufficient. As long as your programs are going to need both function-scoped and block-scoped variables, the most sensible and readable approach is to use both var and let together, each for their own best purpose.

There are other semantic and operational reasons to choose var or let in different scenarios. We’ll explore the case for var and let in more detail in Appendix A.

WARNING:
My recommendation to use both var and let is clearly controversial and contradicts the majority. It’s far more common to hear assertions like, “var is broken, let fixes it” and, “never use var, let is the replacement.” Those opinions are valid, but they’re merely opinions, just like mine. var is not factually broken or deprecated; it has worked since early JS and it will continue to work as long as JS is around.

Where To let?

My advice to reserve var for (mostly) only a top-level function scope means that most other declarations should use let. But you may still be wondering how to decide where each declaration in your program belongs?

POLE already guides you on those decisions, but let’s make sure we explicitly state it. The way to decide is not based on which keyword you want to use. The way to decide is to ask, “What is the most minimal scope exposure that’s sufficient for this variable?”

Once that is answered, you’ll know if a variable belongs in a block scope or the function scope. If you decide initially that a variable should be block-scoped, and later realize it needs to be elevated to be function-scoped, then that dictates a change not only in the location of that variable’s declaration, but also the declarator keyword used. The decision-making process really should proceed like that.

If a declaration belongs in a block scope, use let. If it belongs in the function scope, use var (again, just my opinion).

But another way to sort of visualize this decision making is to consider the pre-ES6 version of a program. For example, let’s recall diff(..) from earlier:

  1. function diff(x,y) {
  2. var tmp;
  3. if (x > y) {
  4. tmp = x;
  5. x = y;
  6. y = tmp;
  7. }
  8. return y - x;
  9. }

In this version of diff(..), tmp is clearly declared in the function scope. Is that appropriate for tmp? I would argue, no. tmp is only needed for those few statements. It’s not needed for the return statement. It should therefore be block-scoped.

Prior to ES6, we didn’t have let so we couldn’t actually block-scope it. But we could do the next-best thing in signaling our intent:

  1. function diff(x,y) {
  2. if (x > y) {
  3. // `tmp` is still function-scoped, but
  4. // the placement here semantically
  5. // signals block-scoping
  6. var tmp = x;
  7. x = y;
  8. y = tmp;
  9. }
  10. return y - x;
  11. }

Placing the var declaration for tmp inside the if statement signals to the reader of the code that tmp belongs to that block. Even though JS doesn’t enforce that scoping, the semantic signal still has benefit for the reader of your code.

Following this perspective, you can find any var that’s inside a block of this sort and switch it to let to enforce the semantic signal already being sent. That’s proper usage of let in my opinion.

Another example that was historically based on var but which should now pretty much always use let is the for loop:

  1. for (var i = 0; i < 5; i++) {
  2. // do something
  3. }

No matter where such a loop is defined, the i should basically always be used only inside the loop, in which case POLE dictates it should be declared with let instead of var:

  1. for (let i = 0; i < 5; i++) {
  2. // do something
  3. }

Almost the only case where switching a var to a let in this way would “break” your code is if you were relying on accessing the loop’s iterator (i) outside/after the loop, such as:

  1. for (var i = 0; i < 5; i++) {
  2. if (checkValue(i)) {
  3. break;
  4. }
  5. }
  6. if (i < 5) {
  7. console.log("The loop stopped early!");
  8. }

This usage pattern is not terribly uncommon, but most feel it smells like poor code structure. A preferable approach is to use another outer-scoped variable for that purpose:

  1. var lastI;
  2. for (let i = 0; i < 5; i++) {
  3. lastI = i;
  4. if (checkValue(i)) {
  5. break;
  6. }
  7. }
  8. if (lastI < 5) {
  9. console.log("The loop stopped early!");
  10. }

lastI is needed across this whole scope, so it’s declared with var. i is only needed in (each) loop iteration, so it’s declared with let.

What’s the Catch?

So far we’ve asserted that var and parameters are function-scoped, and let/const signal block-scoped declarations. There’s one little exception to call out: the catch clause.

Since the introduction of try..catch back in ES3 (in 1999), the catch clause has used an additional (little-known) block-scoping declaration capability:

  1. try {
  2. doesntExist();
  3. }
  4. catch (err) {
  5. console.log(err);
  6. // ReferenceError: 'doesntExist' is not defined
  7. // ^^^^ message printed from the caught exception
  8. let onlyHere = true;
  9. var outerVariable = true;
  10. }
  11. console.log(outerVariable); // true
  12. console.log(err);
  13. // ReferenceError: 'err' is not defined
  14. // ^^^^ this is another thrown (uncaught) exception

The err variable declared by the catch clause is block-scoped to that block. This catch clause block can hold other block-scoped declarations via let. But a var declaration inside this block still attaches to the outer function/global scope.

ES2019 (recently, at the time of writing) changed catch clauses so their declaration is optional; if the declaration is omitted, the catch block is no longer (by default) a scope; it’s still a block, though!

So if you need to react to the condition that an exception occurred (so you can gracefully recover), but you don’t care about the error value itself, you can omit the catch declaration:

  1. try {
  2. doOptionOne();
  3. }
  4. catch { // catch-declaration omitted
  5. doOptionTwoInstead();
  6. }

This is a small but delightful simplification of syntax for a fairly common use case, and may also be slightly more performant in removing an unnecessary scope!