A List Apart

Menu

Fixing Variable Scope Issues with ECMAScript 6

Variable scope has always been tricky in JavaScript, particularly when compared to more structured languages like C and Java. For years, there wasn’t much talk about it because we had few options for really changing it. But ECMAScript 6 introduced some new features to help give developers more control of variable scope. Browser support is pretty great and these features are ready to use for most developers today. But which to choose? And what, exactly, do they do?

Article Continues Below

This article spells out what these new features are, why they matter, and how to use them. If you’re ready to take more control over variable scope in your projects or just want to learn the new way of doing things, read on.

Variable scope: a quick primer

Variable scope is an important concept in programming, but it can confuse some developers, especially those new to programming. Scope is the area in which a variable is known. Take a look at the following code:

var myVar = 1;

function setMyVar() {
  myVar = 2;
}

setMyVar();

console.log(myVar);

What does the console log read? Not surprisingly, it reads 2. The variable myVar is defined outside of any function, meaning it’s defined in the global scope. Consequently, every function here will know what myVar is. In fact, even functions in other files that are included on the same page will know what this variable is.

Now consider the following code:

function setMyVar() {
  var myVar = 2;
}

setMyVar();

console.log(myVar);

All we did was move where the variable was declared. So what does the console log read now? Well, it throws a ReferenceError because myVar is not defined. That’s because the var declaration here is function-level, making the scope extend only within the function (and any potential functions nested in it), but not beyond. If we want a variable’s scope to be shared by two or more functions on the same level, we need to define the variable one level higher than the functions.

Here’s the tricky thing: most websites and apps don’t have all of the code written by one developer. Most will have several developers touching the code, as well as third-party libraries and frameworks thrown into the mix. And even if it’s just one developer, it’s common to pull JavaScript in from several places. Because of this, it’s generally considered bad practice to define a variable in the global scope—you never know what other variables other developers will be defining. There are some workarounds to share variables among a group of functions—most notably, the module pattern and IIFEs in object-oriented JavaScript, although encapsulating data and functions in any object will accomplish this. But variables with scopes larger than necessary are generally problematic.

The problem with var

Alright, so we’ve got a handle on variable scope. Let’s get into something more complex. Take a look at the following code:

function varTest() {
  for (var i = 0; i < 3; i++) {
    console.log(i);
  }
  console.log(i);
}

varTest();

What are the console logs? Well, inside the loop, you get the iteration variable as it increments: 0, 1, 2. After that, the loop ends and we move on. Now we try to reference that same variable outside of the for loop it was created in. What do we get?

The console log reads 3 because the var statement is function-level. If you define a variable using var, the entire function will have access to it, no matter where it is defined in that function.

This can get problematic when functions become more complex. Take a look at the following code:

function doSomething() {
  var myVar = 1;
  if (true) {
    var myVar = 2;
    console.log(myVar);
  }
  console.log(myVar);
}

doSomething();

What are the console logs? 2 and 2. We define a variable equal to 1, and then try to redefine the same variable inside the if statement. Since those two exist in the same scope, we can’t define a new variable, even though that’s obviously what we want, and the first variable we set is overwritten inside the if statement.

That right there is the biggest shortcoming with var: its scope is too large, which can lead to unintentional overwriting of data, and other errors. Large scope often leads to sloppy coding as well—in general, a variable should only have as much scope as it needs and no more. What we need is a way to declare a variable with a more limited scope, allowing us to exercise more caution when we need to.

Enter ECMAScript 6.

New ways to declare variables

ECMAScript 6 (a new set of features baked into JavaScript, also known as ES6 or ES2015) gives us two new ways to define variables with a more limited scope: let and const. Both give us block-level scope, meaning scope can be contained within blocks of code like for loops and if statements, giving us more flexibility in choosing how our variables are scoped. Let’s take a look at both.

Using let

The let statement is simple: it’s mostly like var, but with limited scope. Let’s revisit that code sample from above, replacing var with let:

function doSomething() {
  let myVar = 1;
  if (true) {
    let myVar = 2;
    console.log(myVar);
  }
  console.log(myVar);
}

doSomething();

In this case, the console logs would read 2 and 1. This is because an if statement defines a new scope for a variable declared with let—the second variable we declare is actually a separate entity than the first one, and we can set both independently. But that doesn’t mean that nested blocks like that if statement are completely cut off from higher-level scopes. Observe:

function doSomething() {
  let myVar = 1;
  if (true) {
    console.log(myVar);
  }
}

doSomething();

In this case, the console log would read 1. The if statement has access to the variable we created outside of it and is able to log that. But what happens if we try to mix scopes?

function doSomething() {
  let myVar = 1;
  if (true) {
    console.log(myVar);
    let myVar = 2;
    console.log(myVar);
  }
}

doSomething();

You might think that first console log would read 1, but it actually throws a ReferenceError, telling us that myVar is not defined or initialized for that scope. (The terminology varies across browsers.) JavaScript variables are hoisted in their scope—if you declare a variable within a scope, JavaScript reserves a place for it even before you declare it. How that variable is reserved differs between var and let.

console.log(varTest);
var varTest = 1;

console.log(letTest);
let letTest = 2;

In both cases here, we’re trying to use a variable before it’s defined. But the console logs behave differently. The first one, using a variable later declared with var, will read undefined, which is an actual variable type. The second one, using a variable later defined with let, will throw a ReferenceError and tell us that we’re trying to use that variable before it’s defined/initialized. What’s going on?

Before executing, JavaScript will do a quick read of the code and see if any variables will be defined, and hoist them within their scope if they are. Hoisting reserves that space, even if the variable exists in the parent scope. Variables declared with var will be auto-initialized to undefined within their scope, even if you reference them before they’re declared. The big problem is that undefined doesn’t always mean you’re using a variable before it’s defined. Look at the following code:

var var1;
console.log(var1);

console.log(var2);
var var2 = 1;

In this case, both console logs read undefined, even though different things are happening. Variables that are declared with var but have no value will be assigned a value of undefined; but variables declared with var that are referenced within their scope before being declared will also return undefined. So if something goes wrong in our code, we have no indication which of these two things is happening.

Variables defined with let are reserved in their block, but until they’re defined, they go into the Temporal Dead Zone (TDZ)—they can’t be used and will throw an error, but JavaScript knows exactly why and will tell you.

let var1;
console.log(var1);

console.log(var2);
let var2 = 1;

In this case, the first console log reads undefined, but the second throws a ReferenceError, telling us the variable hasn’t been defined/initialized yet.

So, using var, if we see undefined, we don’t know if the variable has been defined and just doesn’t have a value, or if it hasn’t been defined yet in that scope but will be. Using let, we get an indication of which of these things is happening—much more useful for debugging.

Using const

The const statement is very similar to let, but with one major exception: it does not allow you to change the value once initialized. (Some more complex types, like Object and Array, can be modified, but can’t be replaced. Primitive types, like Number and String, cannot change at all.) Take a look at the following code:

let mutableVar = 1;
const immutableVar = 2;

mutableVar = 3;
immutableVar = 4;

That code will run fine until the last line, which throws a TypeError for assignment to a constant variable. Variables defined with const will throw this error almost any time you try to reassign one, although object mutation can cause some unexpected results.

As a JavaScript developer, you might be wondering what the big deal is about immutable variables. Constant variables are new to JavaScript, but they’ve been a part of languages like C and Java for years. Why so popular? They make us think about how our code is working. There are some cases where changing a variable can be harmful to the code, like when doing calculations with pi or when you have to reference a certain HTML element over and over:

const myButton = document.querySelector('#my-button');

If our code depends on that reference to that specific HTML element, we should make sure it can’t be reassigned.

But the case for const goes beyond that. Remember our best practice of only giving variables the scope they need and no more. In that same line of thought, we should only give variables the mutability they need and no more. Zell Liew has written much more on the subject of immutable variables, but the bottom line is that making variables immutable makes us think more about our code and leads to cleaner code and fewer surprises.

When I was first starting to use let and const, my default option was let, and I would use const only if reassignment would cause harm to the code. But after learning more about programming practices, I changed my mind on this. Now, my default option is const, and I use let only if reassignment is necessary. That forces me to ask if reassignment for a variable is really necessary—most of the time, it’s not.

Is there a case for var?

Since let and const allow for more careful coding, is there a case for var anymore? Well, yes. There are a few cases where you’d want to use var over the new syntax. Give these careful consideration before switching over to the new declarations.

Variables for the masses

Variables declared with var do have one thing that the others don’t, and it’s a big one: universal browser support. 100% of browsers support var. Support is pretty great for both let and const, but you have to consider how differently browsers handle JavaScript it doesn’t understand vs. CSS it doesn’t understand.

If a browser doesn’t support a CSS feature, most of the time that’s just going to mean a display bug. Your site may not look the same as in a supporting browser, but it’s most likely still usable. If you use let and a browser doesn’t support it, that JavaScript will not work. At all. With JavaScript being such an integral part of the web today, that can be a major problem if you’re aiming to support old browsers in any way.

Most support conversations pose the question, “What browsers do we want to deliver an optimal experience for?” When you’re dealing with a site containing core functionality that relies on let and const, you’re essentially asking the question, “What browsers do we want to ban from using our site?” This should be a different conversation than deciding whether you can use display: flex. For most websites, there won’t be enough users of non-supporting browsers to worry about. But for major revenue-generating sites or sites where you’re paying for traffic, this can be a serious consideration. Make sure that risk is alright with your team before proceeding.

If you need to support really old browsers but want to use let and const (and other new, ES6 constructs), one solution is to use a JavaScript transpiler like Babel to take care of this for you. With Babel, you can write modern JavaScript with new features and then compile it into code that’s supported by older browsers.

Sound too good to be true? Well, there are some caveats. The resulting code is much more verbose than you’d write on your own, so you end up with a much larger file than necessary. Also, once you commit to a transpiler, that codebase is going to be stuck with that solution for a while. Even if you’re writing valid ECMAScript 6 for Babel, dropping Babel later will mean testing your code all over again, and that’s a hard sell for any project team when you have a version that’s working perfectly already. When’s the next time you’re going to rework that codebase? And when is that IE8 support not going to matter anymore? It might still be the best solution for the project, but make sure you’re comparing those two timelines.

And for the next trick ...

There is one more thing var can do that the others can’t. This is a niche case, but let’s say you have a situation like this:

var myVar = 1;

function myFunction() {
  var myVar = 2;
  // Oops! We need to reference the original myVar!
}

So we defined myVar in the global scope, but later lost that reference because we defined it in a function, yet we need to reference the original variable. This might seem silly, because you can ordinarily just pass the first variable into the function or rename one of them, but there may be some situations where your level of control over the code prevents this. Well, var can do something about that. Check it out:

var myVar = 1;

function myFunction() {
  var myVar = 2;
  console.log(myVar); // 2
  console.log(window.myVar); // 1
}

When a variable is defined on the global scope using var, it automatically attaches itself to the global window object—something let and const don’t do. This feature helped me out once in a situation where a build script validated JavaScript before concatenating files together, so a reference to a global variable in another file (that would soon be concatenated into the same file upon compilation) threw an error and prevented compilation.

That said, relying on this feature often leads to sloppy coding. This problem is most often solved with greater clarity and smaller margin of error by attaching variables to your own object:

let myGlobalVars = {};
let myVar = 1;
myGlobalVars.myVar = myVar;

function myFunction() {
  let myVar = 2;
  console.log(myVar); // 2
  console.log(myGlobalVars.myVar); // 1
}

Yes, this requires an extra step, but it reduces confusion in working around something you’re not really supposed to be doing anyway. Nonetheless, there may be times when this feature of var is useful. Try to find a cleaner workaround before resorting to this one, though.

Which do I use?

So how do you choose? What’s the priority for using these? Here’s the bottom line.

First question: are you supporting IE10 or really old versions of other browsers in any way? If the answer is yes, and you don’t want to go with a transpiler solution, you need to choose var.

If you’re free to use the features that are new in ES6, start by making every variable a const. If a variable needs to be reassigned (and try to write your code so it doesn’t), switch it to let.

Scoping for the future

ECMAScript 6 statements like let and const give us more options for controlling variable scope in our websites and apps. They make us think about what our code is doing, and support is great. Give it careful consideration, of course, but coding with these declarations will make your codebase more stable and prepare it for the future.

4 Reader Comments

Load Comments