Skip to content

Instantly share code, notes, and snippets.

@getify
Last active March 2, 2023 21:24
Show Gist options
  • Star 49 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save getify/712d994419326b53cabe20138161908b to your computer and use it in GitHub Desktop.
Save getify/712d994419326b53cabe20138161908b to your computer and use it in GitHub Desktop.
In defense of using blocks to create localized scope for variables... (part 1 of 2)

In defense of blocks for local scopes (Part 1)


[EDIT: I've added a part 2 to this blog post, to address some of the feedback that has come up: https://gist.github.com/getify/706e5e10822a298375da40f9cc1fa295]


Recently, this article on "The JavaScript Block Statement" came out, and it received some good discussion in a reddit thread. But the general reaction seems to be against this approach. That saddens me.

For context, consider this bit of code:

// some previous code

let life = getMeaningOfLife();
console.log(`The meaning of life: ${life}`);

// subsequent unrelated/unconnected code

If those few lines are rather self contained -- that is, they don't create any declarations that are needed in subsequent code -- should they just hang out there alone, or should they be set apart in some way? The original blog post suggests this style instead:

// some previous code

{
    let life = getMeaningOfLife();
    console.log(`The meaning of life: ${life}`);
}

// subsequent unrelated/unconnected code

If your primary/only language has been JS, you may never have seen a bare block used like that, but it's actually been valid JS since 1995'ish (from the beginning). But that's not just a quirk of JS, it's actually quite well established precedent in many other languages, such as C++ and Java. For decades, developers of all sorts of languages have known about the merits of localizing variable declarations.

What the problem IS NOT

First of all, we're not concerned with needing some place to create "private" or "isolated" scopes. Module scopes and private class methods are all solutions to the PROBLEM WE DO NOT HAVE HERE. So they're irrelevant red herrings. We're talking about almost the reverse problem, honestly.

The vast majority of all JS developers have already been quite comfortable with the importance of block scoping inside of blocks like if or loops, to narrow the visibility of a declaration.

Certainly, you've done things like this regularly in your code, right?

if (x > y) {
   const tmp = x;   // <--- this declaration is block-scoped!
   x = y;
   y = tmp;
}

But why didn't you just do:

let tmp;
if (x > y) {
   tmp = x;
   x = y;
   y = tmp;
}

The reason, as we probably all know, is that limiting the scope of a declaration to the lines where it's only needed, and not leaving it visible to the rest of the code in that same enclosing scope (function, etc) IS A GOOD IDEA AND BEST PRACTICE. That's not a novel assertion. Developers have been doing block-scoping for decades in all sorts of languages. And when ES6 added let / const, JS developers happily started following suit.

OK, so just so we're clear, putting a declaration inside a block, like an if or a loop, limits its scope visibility, which improves the code (readability, maintainability, durability, etc). That's not controversial in any way.

Now, what if at some point in your code, you didn't have a natural need for if or loop block, to nest your declarations inside of? Are you just out of luck, and your declarations have to sit at the top function level and be visible to its entire scope? NO.

Returning to the trivial life example from earlier, you could do something silly like this:

// some previous code

if (true) {                            // <--- wtf, what kind of weirdness is this!?
   let life = getMeaningOfLife();
   console.log(`The meaning of life: ${life}`);
}

// subsequent unrelated/unconnected code

Hopefully we can all agree that while we accomplished our goal of limiting the scope visibility of the life declaration, we did so in an absurd way by using a confusing if (true) construct.

Basically, what the original blog post (the one I'm defending) asserted is, we don't need to contrive something silly like that (nor do we need more heavy-handed solutions like functions). We can actually just do:

// some previous code

{                                   // <--- whoa, what's this bare block?
   let life = getMeaningOfLife();
   console.log(`The meaning of life: ${life}`);
}

// subsequent unrelated/unconnected code

From this perspective, is this really such a controversial claim? I don't think so at all. If you've ever put a let / const inside an if or loop, why wouldn't you be comfortable putting it inside of a bare { .. } block?

In my You Don't Know JS Yet (2nd ed) "Scope & Closures" book, I wrote about managing scopes and declaration visibility with blocks. I've been encouraging this style for a long time. But alas, it seems that many in JS land are more resistent.

If you're still not convinced, I invite you to read on.

What's the actual problem?

If the life variable (from above) is re-used somewhere else in the same (or lower) scope, there might be a "collision". A second let life declaration might raise an early/static JS syntax error. But worse, a life = .. assignment might accidentally re-assign that variable in a way that causes an unexpected bug, which not become apparent until later.

In fact, the re-assignment concern is one of the main drivers behind the movement by many in JS to suggest that const should be the primary declarator (instead of let, or... gasp... var). If you declare something with const, it cannot be re-assigned without a runtime JS exception being raised.

In Appendix A of the "Scope & Closures" book I just linked to, I wrote about the const / let / var question extensively, including a reality-dose for why const doesn't really solve as many problems as people claim it does.

The original blog post suggests variable collision as being the primary motivator for this approach. I generally agree; one of the main themes of "Scope & Closures" is that it's critical that we learn to properly use scope to limit visibility of declarations for that (and other!) reasons.

But if we want to collaborate to solve a problem, we need to first agree what the actual problem is first, before we can really debate about different solutions. The always-const movement in JS is IMO a classic example of not understanding the problem and then just half-solving it (at best).

The real problem we need to look at is, should a variable ever be more broadly visible to more of the program than is strictly necessary? If you're not clear on the answer to that fundamental question, I invite you to read my book for a very deep dive.

But if you already agree with me, the answer is a resounding NO. Variable collision (or re-assignment, if you prefer) is just one aspect of this broader concern. If we address proper scope management, such as using a block to localize a declaration, it turns out we'll solve all those problems. If we only worry about variable collision, we miss the bigger picture.

That's part of why the reddit discussion thread was (predictably) a bit troubling, because the bigger picture is rarely considered in such discussions.

Is a scope block really that bad?

Back to that reddit discussion. In particular, this comment was against the idea of using blocks like that -- to create local scopes for one or more declarations -- with some others even suggesting they would reject PRs at work if a developer on the team did this.

That's a pretty strong negative emotion against a syntactically valid approach in JS (and an approach that's been common in other languages for ages). So what's the reasoning for such negativity? TBH, I'm not quite sure. It might be because of hatred for extra indentation (invoking the ever-present culture war over tabs vs spaces)? It might be because people fell in love with const as the "solution" and anything else is abhorrent mis-use of the language? I don't know.

Nevertheless, I yet again chimed in with my support for the blocks-as-variable-localization pattern, and that's when some interesting discussion between myself and /u/i_like_idli occurred.

Why not just use a function for scope?

In particular, /u/i_like_idli asserted that functions were a better form to create a localized scope, if the only concern was preventing variable name collisions. As I asserted earlier, there's more to the big picture than that. So, at /u/i_like_idli's later request, I'm going to collect my thoughts from that thread here into a post to make it easier to reference for posterity.

Let's conjure a slightly more involved example than the life one above:

// some previous code

let x = foo();
let y = Math.abs(whatever * x) * 1000;
try {
  bar(x,y,"something");
}
catch (err) { /* .. */ }
showConfirmation();

// subsequent unrelated/unconnected code

OK, yes, contrived foo/bar gibberish. I'm not super creative when it comes to making up examples on the fly when chatting on reddit. But I'm sticking with what I put on reddit so as to not break the context from those discussions. Hopefully you can look past that. This is just a representation for a set of connected code that does some stuff. And it's relatively unrelated/independent-of the surrounding code, either before or after it.

The question posed originally was, should it just sit there by itself. No. It should be cordoned off in some way.

So should it be in a function?

Well, it depends. If the code is going to be called more than once, likely yes. But odds are you already know that. If it's only going to be run once, should we still use a function?

/u/i_like_idli suggested that one key benefit of a function is that the name of the function provides a built-in opportunity to describe/label the block of code, like this:

// some previous code

function describeThePurposeOfThisBlock() {
    let x = foo();
    let y = Math.abs(whatever * x) * 1000;
    try {
        bar(x,y,"something");
    }
    catch (err) { /* .. */ }
    showConfirmation();
}
describeThePurposeOfThisBlock();

// subsequent unrelated/unconnected code

True, function names are a great opportunity to think about the purpose of code carefully in coming up with a good name.

But there's a subtle downside here. Besides the extra verbosity (the descriptor of the block is repeated), we had to create a lexical identifier name (describeThePurposeOfThisBlock) in the surrounding scope, so that we could invoke the function!

Ugh. OK, so we can use the tried and true IIFE from nearly two decades ago:

// some previous code

(function describeThePurposeOfThisBlock(){
    let x = foo();
    let y = Math.abs(whatever * x) * 1000;
    try {
        bar(x,y,"something");
    }
    catch (err) { /* .. */ }
    showConfirmation();
})();

// subsequent unrelated/unconnected code

Now the describeThePurposeOfThisBlock identifier is only in its own scope and not polluting the surrounding scope. But... it's still an identifier in the scope, meaning it could have a collision. It's also a bit ugly and verbose to have those extra parentheses-sets. And the function keyword here clutters things for us, because we don't actually care that it's a function (since it's called once), we just want the scope.

Also keep in mind that function calls are not free. They don't cost a lot in terms of performance, but if you got in the habit of making function calls any time you need a block of scope to narrow variable visibility, you might easily start chewing up unnecessary performance. The JS engine might be able to inline such functions, but there's no guarantee that would happen, and you're leaving it up to the smarts of the engine to undo the lesser-performant code. I don't think anyone would rationally suggest that it's a good idea to write knowingly-worse code and rely on the engine to fix your mistakes. Right?

Also, don't forget that adding another function that's called means we are lengthening the call-stack, which means that additional function will show up at the top of the call-stack if an exception occurs with the code in question. Not a huge deal, but it could slightly convolute the debugging process.

Moreover, function boundaries change the definitions/behaviors of certain mechanisms, namely this, arguments, new.target, super, return, await, yield, break, and continue. If the code block in question had been using any of those, wrapping a function around the code would affect those, meaning the code itself would have to be modified to suit. Ick.

Arrow functions could resolve some of that:

// some previous code

(() => {
    let x = foo();
    let y = Math.abs(whatever * x) * 1000;
    try {
        bar(x,y,"something");
    }
    catch (err) { /* .. */ }
    showConfirmation();
})();

// subsequent unrelated/unconnected code

The this, arguments, new.target, and super mechanisms are not defined or interrupted in any way by an => arrow function boundary. But return / await / yield / break / continue are all still affected.

And worse, arrow functions are lexically anonymous, so we lost the option of a helpful describeThePurposeOfThisBlock label.

Hopefully you can tell that functions are not the ideal solution to create a single-use scope -- not when there's other facilities in JS that are perfectly well-suited for that task.

Back To The Block

I suggested to /u/i_like_idli that we could use a block with a code comment, to get the descriptive label:

// some previous code

{ // describe the purpose of this block
    let x = foo();
    let y = Math.abs(whatever * x) * 1000;
    try {
        bar(x,y,"something");
    }
    catch (err) { /* .. */ }
    showConfirmation();
}

// subsequent unrelated/unconnected code

That's what I've always done. It's clean and compact, and (shockingly!) it's using comments for exactly their intended purpose: to describe the WHY of a set of code.

But I later realized, maybe there's an even more apt mechanism for labeling the purpose of the block: labeled blocks!

// some previous code

purposeOfThisBlock: {
    let x = foo();
    let y = Math.abs(whatever * x) * 1000;
    try {
        bar(x,y,"something");
    }
    catch (err) { /* .. */ }
    showConfirmation();
}

// subsequent unrelated/unconnected code

Labeled blocks are perhaps more hated in "modern JS" than the var keyword, if that's even possible. They're often treated as a legacy behavior related to the confusion of "goto"-style flow control, a sentiment that dates back nearly a half-dozen decades.

Labeled blocks can be abused, for sure. But they're yet another feature of JS that I think is actually the right tool for certain jobs. And for the job of labeling the purpose of a block of code, I don't think there's any other more right tool than a labeled block.

But what about...?

After you stop screaming at the screen for my heresy in advocating labeled blocks, you're now ready to "what about...?" me on the naming/collision question, I'm sure of it.

The purposeOfThisBlock label looks like a normal JS identifier (and has basically the same syntactic character rules). But it's not a lexical identifier (variable), I promise. Don't believe me? Copy-paste this code into your nearest browser devtools console:

"use strict";
something: {
    console.log(something);
}

That code throws an exception because something is a block-label, not a variable declaration.

Moreover, labels don't have to be unique (if they're not nested), so...

"use strict";
something: {
    console.log("this works");
}
something: {
    console.log("...and so does this!");
}

So our collision problems are drastically alleviated. That doesn't mean the name doesn't matter -- it does! -- but it means the JS engine won't care. So the concern for the label's name is entirely up to us, for clarity and consistency and avoiding confusion for our readers. Don't overload the reader by reusing common/generic label names.

But is it worth it?

As /u/i_like_idli did, you might still be wondering if the (labeled) block, and its unfamiliarity in JS land, is worth it, just to prevent some collision/re-assignment.

I mentioned a few times earlier, there's more benefits than that. Here's a few:

  1. Using a block to localize a declaration communicates a defined scope lifetime for that variable, which means that beyond that scope, the reader no longer needs to mentally juggle whether that variable is still in scope. This improves readability, and also offers the opportunity to group sets of behavior into logical groups (much like functions do, but more lightweight).

  2. It offers an opportunity for the garbage-collector (GC) to more quickly clean up released memory from inside the block even as the rest of the surrounding scope is processing. That's not guaranteed, but it's at least more possible given that the engine can definitively prove that those variables have gone out of scope.

  3. And finally, if you used the labeled-block form, as shown above, you actually gain the ability to "early return" from the block by breaking out. Yes, functions have return, but that statement has a semantic that's naturally tied to returning some value, and return; by itself is arguably a bit less natural. By contrast, in the block, you can use a labeled-break.

    Consider:

    // some previous code
    
    purposeOfThisBlock: {
        let x = foo();
        let y = Math.abs(whatever * x) * 1000;
        try {
            bar(x,y,"something");
        }
        catch (err) {
            break purposeOfThisBlock;  // <-- here
        }
        showConfirmation();
    }
    
    // subsequent unrelated/unconnected code

    You've probably seen, and maybe even used, break inside of loops or switch statements, usually just as break;.

    By contrast, the break purposeOfThisBlock above is a labeled-break statement, which breaks out of the block of the same label name. In non-loop/non-switch statements, break must be labeled.

    But unlike return; which is arguably a bit of conflation of a value-return mechanism for secondary flow-control purposes, break with a label is doing exactly what it's intended to do, and the semantic it sends is super obvious: "break out of the code block that's been doing this task".

    You may never choose to use a labeled-break like this, and that's OK. I haven't either. But if you need to break out of a block, and the block is labeled, this is a hidden gem of JS that yet again delights me in its ability to bend to our every need.

Block End

Managing scopes (to limit variable visibility) is a larger topic than just preventing variable collision/re-assignment. That's why const isn't sufficient.

But functions are also insufficient at declaring localized, single-use scopes. The block, and moreover, the labeled-block, is the better mechanism for doing this.

Don't go overboard and put every single statement into its own block. That's strawman territory. But do look for places in your code where you can add some extra readability, and do so using JS's built in syntax/mechanisms to your advantage. Wrap blocks around code, with let declarations inside them, to create scopes and manage your variable visibility.


[EDIT: I've added a part 2 to this blog post, to address some of the feedback that has come up: https://gist.github.com/getify/706e5e10822a298375da40f9cc1fa295]

@thelinuxlich
Copy link

what is bad is using let where it's not needed

@getify
Copy link
Author

getify commented Jul 3, 2022

Chewing on my own dog food... I just made this little change to an app I'm building, to clarify some local scopes (with labeled blocks):

getify/youperiod.app@7a8ce5a

It's not a huge difference, but I think it helps a little, and I certainly don't think it makes the code less readable in any way.

@pjdevries
Copy link

what is bad is using let where it's not needed

I'm curious. Why is that? Can you elaborate?

@lawrence-dol
Copy link

Completely agree, and I've been using labelled blocks in C, Java, and JavaScript for decades, for this very purpose.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment