For and against `let`

In this post I'm going to examine the case for (and perhaps against?) one of the new features coming in JavaScript ES6: the let keyword. let enables a new form of scoping not previously accessible generally to JS developers: block scoping.

Function Scope

Let's briefly review the basics of function scoping -- if you need more indepth coverage, check out my "You Don't Know JS: Scope & Closures" book, part of the "You Don't Know JS" book series.

Consider:

foo(); // 42 function foo() { var bar = 2; if (bar > 1 || bam) { var baz = bar * 10; } var bam = (baz * 2) + 2; console.log( bam ); }

You may have heard the term "hoisting" to describe how JS var declarations are treated within scopes. It's not exactly a technical description for how it works, but more a metaphor. But for our purposes here, it's good enough to illustrate. That above snippet is essentially treated as if it had been written like:

function foo() { var bar, baz, bam; bar = 2; if (bar > 1 || bam) { baz = bar * 10; } bam = (baz * 2) + 2; console.log( bam ); } foo(); // 42

As you can see, the foo() function declaration was moved (aka "hoisted", aka lifted) to the top of its scope, and similarly the bar , baz , and bam variables were hoisted to the top of their scope.

Because JS variables have always behaved in this hoisting manner, many developers choose to automatically put their var declarations at the top of each (function) scope, so as to match code style to its behavior. And it's a perfectly valid way of going about things.

But have you ever seen code which does that, but also will do things like this in the same code:

for (var i=0; i<10; i++) { // .. }

That is also extremely common. Another example that's fairly common:

var a, b; // other code // later, swap `a` and `b` if (a && b) { var tmp = a; a = b; b = tmp; }

The var tmp inside the if block sorta violates the ostensible "move all declarations to the top" coding style. Same of the var i in the for loop in the earlier snippet.

In both cases, the variables will "hoist" anyway, so why do developers still put those variable declarations deeper into the scope instead of at the top, especially if all the other declarations have already been manually moved?

Block Scoping

The most salient reason is because developers (often instinctively) want some variables to act as if they belong to a smaller, more limited section of the scope. In specific terms, there are cases where we want to scope a variable declaration to the block that it's solely associated with.

In the for (var i=..) .. case, it's almost universal that the developer intends for the i to only be used for the purposes of that loop, and not outside of it. In other words, the developer is putting the var i declaration in the for loop to stylistically signal to everyone else -- and their future self! -- that the i belongs to the for loop only. Same with the var tmp inside that if statement. tmp is a temporary variable, and only exists for the purposes of that if block.

Stylistically, we're saying: "don't use the variable anywhere else but right here".

Principle of Least Privilege

There's a software engineering called "principle of least privilege (or exposure)", which suggests that proper software design hides details unless and until it's necessary to expose them. We often do exactly this in module design, by hiding private variables and functions inside a closure, and exposing only a smaller subset of functions/properties as the public API.

Block scoping is an extension of this same mindset. What we're suggesting is, proper software puts variables as close as possible, and as far down in scoping/blocking as possible, to where it's going to be used.

You already instinctively know this exact principle. You already know that we don't make all variables global, even though in some cases that would be easier. Why? Because it's bad design. It's a design that will lead to (unintentional) collisions, which will lead to bugs.

So, you stick your variables inside the function they are used by. And when you nest functions inside of other functions, you nest variables inside those inner functions, as necessary and appropriate. And so on.

Block scoping simply says, I want to be able to treat a { .. } block as a scope, without having to make a new function to encapsulate that scope.

You're following the principle by saying, "If I'm going to only use i for this for loop, I'll put it right in the for loop definition."

JS Missing Block Scoping

Unfortunately, JS has not historically had any practical way to enforce this scoping style, so it's been up to best behavior to respect the style being signaled. Of course, the lack of enforcement means these things get violated, and sometimes it's OK while other times it leads to bugs.

Other languages (e.g., Java, C++) have true block scoping, where you can declare a variable to belong to a specific block instead of to the surrounding scope/function. Developers from those languages know well the benefits of using block scoping for some of their declarations.

They often feel JS has been lacking in expressive capability by missing a way to make an inline scope within a { .. } block instead of the heavier-weight inline function definition (aka IIFE -- Immediately Invoked Function Expression).

And they're totally right. JavaScript has been missing block scoping. Specifically, we've been missing a syntactic way to enforce what we already are comfortable expressing stylistically.

Not Everything

Even in languages that have block scoping, not every variable declaration ends up block scoped.

Take any well-written code base from such a language, and you are certainly going to find some variable declarations that exist at the function level, and others which exist at smaller block levels. Why?

Because that's a natural requirement of how we write software. Sometimes we have a variable we're going to use everywhere in the function, and sometimes we have a variable that we're going to use in just a very limited place. It's certainly not an all-or-nothing proposition.

Proof? Function parameters. Those are variables that exist for the entire function's scope. To my knowledge, no one seriously advances the idea that functions shouldn't have explicit named-parameters because they wouldn't be "block scoped", because most reasonable developers know what I'm asserting here:

Block scoping and function scoping are both valid and both useful, not just one or the other. This kind of code would be quite silly:

function foo() { // <-- Look ma, no named parameters! // .. { var x = arguments[0]; var y = arguments[1]; // do something with `x` and `y` } // .. }

You almost certainly wouldn't write code like that, just to have a "block scoping only" mentality about code structure, anymore than you'd have x and y be global variables in a "global scoping only" mentality.

No, you'd just name the x and y parameters, and use them wherever in the function you need.

The same would be true of any other variable declarations you might create which you intend and need to use across the entire function. You'd probably just put a var at the top of the function and move on.

Introducing let

Now that you understand why block scoping is important, and importantly swallowed the sanity check that it amends function/global scoping rather than replacing it, we can be excited that ES6 is finally introducing a direct mechanism for block scoping, using the let keyword.

In its most basic form, let is a sibling to var . But declarations made with let are scoped to the blocks in which they occur, rather than being "hoisted" to the enclosing function's scope as var s do:

function foo() { a = 1; // careful, `a` has been hoisted! if (a) { var a; // hoisted to function scope! let b = a + 2; // `b` block-scoped to `if` block! console.log( b ); // 3 } console.log( a ); // 1 console.log( b ); // ReferenceError: `b` is not defined }

Yay! let declarations not only express but also enforce block scoping!

Basically, any place a block occurs (like a { .. } pair), a let can create a block scoped declaration inside it. So wherever you need to create limited-scope declarations, use let .

Note: Yeah, let doesn't exist pre-ES6. But quite a few ES6-to-ES5 transpilers exist -- for example: traceur, 6to5, and Continuum -- which will take your ES6 let usage (along with most of the rest of ES6!) and convert it to ES5 (and sometimes ES3) code that will run in all relevant browsers. The "new normal" in JS development, given that JS is going to start rapidly evolving on a feature-by-feature basis, is to use such transpilers as a standard part of your build process. This means that you should start authoring in the latest and greatest JS right now, and let tools worry about making that work in (older) browsers. No longer should you foregoe new language features for years until all previous browsers go away.

Implicit vs Explicit

It's easy to get lost in the excitement of let that it's an implicit scoping mechanism. It hijacks an existing block, and adds to that block's original purpose also the semantics of being a scope.

if (a) { let b = a + 2; }

Here, the block is an if block, but let merely being inside it means that the block also becomes a scope. Otherwise, if let was not there, the { .. } block is not a scope.

Why does that matter?

Generally, developers prefer explicit mechanisms rather than implicit mechanisms, because usually that makes code easier to read, understand, and maintain.

For example, in the realm of JS type coercion, many developers would prefer an explicit coercion over an implicit coercion:

var a = "21"; var b = a * 2; // <-- implicit coercion -- yuck :( b; // 42 var c = Number(a) * 2; // <-- explicit coercion -- much better :) c; // 42

Note: To read more on this side-topic of implicit/explicit coercion, see my "You Don't Know JS: Types & Grammar" book, specifically Chapter 4: Coercion.

When an example shows a block with only one or a few lines of code inside it, it's fairly easy to see if the block is scoped or not:

if (a) { // block is obviously scoped let b; }

But in more real world scenarios, many times a single block can have dozens of lines of code, maybe even a hundred or more. Setting aside the preference/opinion that such blocks shouldn't exist -- they do, it's a reality -- if let is buried way down deep in the middle of all that code, it becomes much harder to know if any given block is scoped or not.

Conversely, if you find a let declaration somewhere in the code, and you want to know to which block it belongs, instead of just visually scanning upwards to the nearest function keyword, you now need to visually scan to the nearest { opening curly brace. That's harder to do. Not a lot harder, but harder nonetheless.

It's a bit more mental tax.

Implicit Hazards

But it's not only a mental tax. Whereas var declarations are "hoisted" to the top of the enclosing function, let declarations are not treated as having been "hoisted" to the top of the block. If you accidentally try to use a block-scoped variable in the block earlier than where its declaration exists, you'll get an error:

if (a) { b = a + 2; // ReferenceError: `b` is not defined // more code let b = .. // more code }

Note: The period of "time" between the opening { and where the let b appears is technically called the "Temporal Dead Zone" (TDZ) -- I'm not making that up! -- and variables cannot be used in their TDZ. Technically, each variable has its own TDZ, and they sort of overlap, again from the opening of the block to the official declaration/initialization.

Since we had previously put the let b = .. declaration further down in the block, and then we wanted to come back and use it earlier in the block, we have a hazard -- a footgun -- where we forgot we needed to go find the let keyword and move it to the earliest usage of the b variable.

In all likelihood, developers are going to get bitten by this TDZ "bug", and they'll eventually learn from that bad experience to always put their let declarations at the top of the block.

And there's another hazard to implict let scoping: the refactoring hazard.

Consider:

if (a) { // more code let b = 10; // more code let c = 1000; // more code if (b > 3) { // more code console.log( b + c ); // more code } // more code }

Let's say later, you realize the if (b > 3) part of the code needs to be moved outside the if (a) { .. block, for whatever reason. You realize you also need to grab the let b = .. declaration to move along with it.

But you don't immediately realize that the block relies on c as well -- because it's a bit more hidden down in the code -- and that c is block scoped to the if (a) { .. block. As soon as you move the if (b > 3) { .. block, now the code breaks, and you have to go find the let c = .. declaration and figure out if it can move, etc.

I could keep coming up with other scenarios -- hypothetical yes, but also extremely informed by lots of experience not only with my own but with others own real world code -- but I think you get the point. It's awfully easy to get yourself into these hazard traps.

If there had been explicit scopes for b and c , it would probably have been a little bit easier to figure out what refactoring is necessary, rather than stumbling along to figure it out implicitly.

Explicit let Scope

If I've convinced you that the implicit nature of let declarations could be a problem/hazard -- if you're not extremely careful, as well as every other developer that ever works on your code! -- then what's the alternative? Do we avoid block scoping entirely?

No! There are better ways.

Firstly, you can force yourself into a style/idiom that not only puts your let declarations at the top of the scope, but also that creates an explicit block for such scope. For example:

if (a) { // more code // make an explicit scope block! { let b, c; // more code b = 10; // more code c = 1000; // more code if (b > 3) { // more code console.log( b + c ); // more code } } // more code }

You'll see here I created a naked { .. } pair, and put the let b, c; declaration right at the very top, even on the same line. I'm making it as clear and explicit as possible that this is a scope block, and that it holds b and c .

If at a later time I need to move some b code around, and I go find the combined scope for b and c , it's not only easier to recognize, but easier to accomplish, that I can move the entire { let b, c; .. } block safely.

Is this perfect? Of course not. But it's better, and has less hazards and less mental tax (even by little bit) than the implicit style/idioms from earlier. I implore all of you, as you begin to use let block scoping, please consider and prefer a more explicit form over the implicit form.

Always Explicit?

In fact, I'd say being explicit is so important that the only exception I've found to that "rule" is that I like and use for (let i=0; .. ) .. . It's debatable if that's implicit or explicit. I'd say it's more explicit than implicit. But it's perhaps not quite as explicit as { let i; for (i=0; ..) .. } .

There's actually a really good reason why for (let i=0; ..) .. could be better, though. It relates to scope closures, and it's very cool and powerful!

{ let i; for (i=1; i<=5; i++) { setTimeout(function(){ console.log("i:",i); },i*1000); } }

That code will, like its more typical var counterpart, not work, in that it'll print out i: 6 five times. But this code does work:

for (let i=1; i<=5; i++) { setTimeout(function(){ console.log("i:",i); },i*1000); }

It'll print out i: 1 , i: 2 , i: 3 , etc. Why?

Because the ES6 specification actually says that let i in a for loop header scopes i not only to the for loop, but to each iteration of the for loop. In other words, it makes it behave like this:

{ let k; for (k=1; k<=5; k++) { let i = k; // <-- new `i` for each iteration! setTimeout(function(){ console.log("i:",i); },i*1000); } }

That's super cool -- it solves a very common problem developers have with closures and loops!

Note: This doesn't work in browsers yet, even those with let . The ES6 spec requires it, but at time of writing, no browsers are compliant on this particular per-iteration nuance. If you want proof, try putting the code into ES6fiddle. See...

Even More Explicit let Scope

OK, so maybe I've convinced you that explicit scopes are a bit better. The disadvantage of the above is that it's not enforceably required that you follow that style/idiom of { let b, c; .. } , which means you or someone else on your team could mess up and not follow it.

There's another option. Instead of using the " let declaration form", we could use the " let block form":

if (a) { // make an explicit scope block! let (b, c) { // .. } }

It's a slight change, but look closely: let (b, c) { .. } creates an explicit block of scope for b and c . It's syntactically requiring b and c to be declared at the top, and it's a block that's nothing but a scope.

In my opinion, this is the best way to use let -based block scoping.

But there's a problem. The TC39 committee voted to not include this particular form of let in ES6. It may come in later, or never, but it's definitely not in ES6.

Ugh. But this isn't the first, nor the last, that something that's more preferable loses out to an inferior option.

So, are we just stuck in the previous form?

Perhaps not. I've built a tool called "let-er", which is a transpiler for " let block form" code. By default, it's in ES6-only mode, and it takes code like:

let (b, c) { .. }

And produces:

{ let b, c; .. }

That's not too awful, is it? It's a pretty simple transformation, actually, to get non-standard " let block form" into standard " let declaration form". After you run let-er for this transformation, you can then use a regular ES6 transpiler to target pre-ES6 environments (browsers, etc).

If you'd like to use let-er standalone without any other transpilers, only for let -based block scoping, you can optionally set the ES3 mode/flag, and it will instead produce this (admittedly hacky junk):

try{throw void 0}catch( b ){try{throw void 0}catch( c ){ .. }}

Yeah, it uses the little-known fact that try..catch has block scoping built into the catch clause.

No one wants to write that code, and no one likes the degraded performance that it brings. But keep in mind, it's compiled code, and it's only for targeting really old browsers like IE6. The slower performance is unfortunate (to the tune of about 10% in my tests), but your code is already running pretty slowly/badly in IE6, so...

Anyway, let-er by default targets standard ES6, and thus plays well with other ES6 tools like standard transpilers.

The choice to make is would you rather author code with let (b, c) { .. } style or is { let b, c; .. } OK enough?

I use let-er in my projects now. I think it's the better way. And I'm hoping maybe in ES7, the TC39 members realize how important it is to add the " let block form" into JS, so that eventually let-er can go away!

Either way, explicit block scoping is better than implicit. Please block scope responsibly.

let Replaces var ?

Some prominent members of the JS community and the TC39 committee like to say, " let is the new var ." In fact, some have literally suggested (hopefully in jest!?) to just do a global find-n-replace of var for let .

I cannot express how incredibly stupid that advice would be.

Firstly, the hazards we mentioned above would be enormously more likely to crop up in your code, as the odds are your code is not perfectly written with respect to var usage. For example, this kind of code is extremely common:

if ( .. ) { var foo = 42; } else { var foo = "Hello World"; }

We can all probably agree it should have been written as:

var foo; if ( .. ) { foo = 42; } else { foo = "Hello World"; }

But it's not written that way yet. Or, you're accidentally doing things like:

b = 1; // .. var b;

Or you're accidentally relying on non-block-scoped closure in loops:

for (var i=0; i<10; i++) { if (i == 2) { setTimeout(function(){ if (i == 10) { console.log("Loop finished"); } },100); } }

So, if you just blindly replace var with let in existing code, there's a pretty good chance that at least some place will accidentally stop working. All of the above would fail if let replaced var , without other changes.

If you're going to retrofit existing code with block scoping, you need to go case by case, carefully, and you need to reason about and rationalize if it's a place where block scoping is appropriate or not.

There will certainly be places where a var was used stylistically, and now a let is better. Fine. I still don't like the implicit usage, but if that's your cup o' tea, so be it.

But there will also be places that, in your analysis, you realize the code has structural issues, where a let would be more awkward, or would create more confusing code. In those places, you may choose to fix the code, but you may also quite reasonably decide to leave var alone.

Here's what bugs me the most about " let is the new var ": it assumes, whether they admit it or not, an elitist view that all JS code should be perfect and follow proper rules. Whenever you bring up those earlier cases, proponents will simply strike back, "well, that code was already wrong."

Sure. But that's a side point, not the main point. It's equally hostile to say, "only use let if your scoping is already perfect, or you're prepared to rewrite it to make it perfect, and keep it perfect."

Other proponents will try to temper it with, "well, just use let for all new code."

This is equivalently elitist, because again it assumes that once you learn let and decide to use it, you'll be expected to write all new code without ever running into any hazard patterns.

I bet TC39 members can do that. They're really smart and really intimate with JS. But the rest of us are not quite so lucky.

let is the new companion to var

The more reasonable and more realistic perspective, the one I take because my primary interface with JS is through the students/attendees that I speak to, teach, and work with, is to embrace refactoring and improving code as a process, not an event.

Sure, as you learn good scoping best practices, you should make code a little better each time you touch it, and sure, your new code should be a little better than your older code. But you don't just flip a switch by reading a book or blog post, and now all of a sudden you have everything perfect.

Instead, I think you should embrace both the new let and the old var as useful signals in your code.

Use let in places you know you need block scoping, and you've specifically thought about those implications. But continue to use var for variables that either cannot easily be block scoped, or which shouldn't be block scoped. There are going to be places in real world code where some variables are going to be properly scoped to the entire function, and for those variables, var is a better signal.

function foo() { var a = 10; if (a > 2) { let b = a * 3; console.log(b); } if (a > 5) { let c = a / 2; console.log(c); } console.log(a); }

In that code, let screams out at me, "hey, I'm block scoped!" It catches my attention, and I thus pay it more care. The var just says, "hey, I'm the same old function-scoped variable, because I'm going to be used across a bunch of scopes."

What about just saying let a = 10 at the top level of the function? You can do that, and it'll work fine.

But I don't think it's a good idea. Why?

First, you lose/degrade the difference in signal between var and let . Now, it's just position that signals the difference, rather than syntax.

Secondly, it's still a potential hazard. Ever had a weird bug in a program, and started throwing try..catch around things to try to figure it out? I sure do.

Oops:

function foo() { try { let a = 10; if (a > 2) { let b = a * 3; console.log(b); } } catch (err) { // .. } if (a > 5) { let c = a / 2; console.log(c); } console.log(a); }

Block scoping is great, but it's not a silver bullet, and it's not appropriate for everything. There are places where function scoping of var s, and indeed of the "hoisting" behavior, are quite useful. These are not abject failures in the language that should be removed. They are things that should be used responsibly, as should let .

Here's the better way to say it: " let is the new block scoping var ". That statement emphasizes that let should replace var only when var was already signaling block scoping stylistically. Otherwise, leave var alone. It's still doing its job pretty well!

Summary

Block scoping is cool, and let gives us that. But be explicit about your block scopes. Avoid implicit let declarations strewn about.

let + var , not s/var/let/ . Just frown then smirk at the next person who tells you, " let is the new var ."

let improves scoping options in JS, not replaces. var is still a useful signal for variables that are used throughout the function. Having both, and using both, means scoping intent is clearer to understand and maintain and enforce. That's a big win!