Variation of Variables

Wed, 31 Oct 2018

Expect the Unexpected

In June 2015, ES6 (aka ES2015) was released and with it, a boatload of new updates to Javascript emerged, including the addition of 2 new variables types, const & let. It was around this time that I was first introduced to Javascript and since then I’ve always followed the (generally accepted) rules, “Use const whenever you can. Use let when you can’t use const. Ne-var”. I had no idea why. In fact, I once sat through an interview in which I was asked a series of true/false Javascript questions related to variable behavior (we’ll see similar questions soon!). I managed to get every question right by choosing the exact opposite of what I would normally expect in a language like Java. Obviously, I didn’t do quite as well on the part when they asked me why (facepalm). So let’s answer this question!

“Why do Javascript’s const, let and var variables behave as they do?”

All the code for this post can be found here. We will be using the following table both as a reference point, and to summarize our findings throughout this post.

Without further ado, Let get started!

Part 1: Scope

Before we try to understand what a block/function scope is, there are a couple key terms/concepts we should understand first.

To start, what is a scope?

Scope refers to the region of visibility of variables. In other words, when we create a variable, the scope is the parts of your program that can see/use it.

There are 2 categories of variables scopes in programming

  1. Lexical (or Static) Scope Based on the program as it exists in the source code, without running it.

  2. Dynamic Scopes Based on what happens while executing the program (“at runtime”).

// Theoretical dynamic scoping
function calledSecond() {
  console.log(declareMe); //dynamic!
}
function calledFirst() {
  var declareMe = "declared!";
  calledSecond();
}
calledFirst();

Variables in JavaScript are lexically scoped - they refer to their nearest lexical environment. The static structure of a program determines the scope of a variable. In other words, it doesn’t care where a function is called from!

In this static scope, a chain of environments is created. A function will record the scope it was created in via the internal property [[scope]]. When a function is called, an environment is created representing that new scope. That environment has a field called outer that point to the outer scope environment thereby changing environments all the way back to the global environment which has its outer env property set to null. This means, to resolve an identifier, this full environment chain is traversed from the current environment to the global environment.

To introduce out next few terms, let’s take add a bit more code to our previous example:

// Initialize a global (outermost) variable*
global.outerScope = "outside";
let level = "1";
console.log(`We start on level ${level}`); // We start on level 1
if (level) {
  // Enter new block
  let level = "2"; // shadowing**
  console.log(`We are now on level ${level} but we can still see ${outerScope}`); // We are now on level 2 but we can still see outside
  if (level) {
    // Enter nested*** block
    let level = "3";
    console.log(`We are on level ${level} but we can still see ${outerScope}`); // We are on level 3 but we can still see outside
  }
  console.log(`We are back on level ${level}`); // We are back on level 2
}
console.log(`We are back on level ${level}`); // We are back on level 1

This function introduces a few key concepts:

  1. Global Scope

The Global Scope refers to the top-level scope - it is accessible everywhere. In Node.js it is controlled by the global object, while in a browser environment it is controlled by the window object. Our focus will be on node.js

The global scope, like any other scope, has its own environment. However, unlike other scopes, this environment is accessible via an object - the global object! It is through this object we can create, read and change global variables.

global.globalVar = "Access me anywhere!";
console.log(globalVar); // Access me anywhere!
globalString = "Change me anywhere!";
console.log(globalString); // Change me anywhere!

You probably haven’t made too many of your own global variables, and there are a few good reasons why!

  • Global variables exist throughout the lifetime of the application, taking up memory and occupying resources
  • Global variables can create implicit coupling between files or variables, making our code less modular, less readable, and harder to debug
  • In multi-threaded languages, global variables can also cause concurrency issues when we have multiple threads accessing the same variable without access modifiers or fail-safes in place. (Node.js is strictly single-threaded and process clusters do not have native communication)

In fact, the global object is actually considered one of Brendan Eich’s “biggest regrets”. So with all these issues do we ever actually use global variables?

  • Process object! (stores information about currently running node processes) The env variable is actually part of the process object

  • The console is also a global instance configured to write to process.stdout and process.stderr!

  • Nested scopes

Recall chain of environments. If a scope is nested within another, then the variables of the outer scope are accessible within the inner scope. The opposite is not true.

  1. Shadowing

If we declare a variable in a nested scope with the same name as a variable declared one of its surrounding scopes, access to the outer variable is blocked in the inner scope and all scopes nested within it - any changes to the inner variable will not affect the outer variable. Access to the outer variable will resume once the inner scope is left.

Phew.. that was a lot. Thankful with that background the next few terms become very simple!

Now that we understand what a variable scope is, we can take another look at our table which tells us:

  • var is function scoped
  • const & let are block scoped

Function scoped means a new local scope is created when we enter a function

Block scoped means a new local scope is created when we enter any block of code (i.e. ifs, loops, or functions)

Let’s see this in action

let blockScope = "initial";
var functionScope = "initial";
if (true) {
  let blockScope = "new";
  var functionScope = "new";
  console.log(`We see the ${blockScope} value`); // new
  console.log(`We see the ${functionScope} value`); // new
}
console.log(`We now see the ${blockScope} value`); // old
console.log(`We still see the ${functionScope} value b/c it did not create a new variable`); // new

Now let try the same code but with a function instead of an if statement

let blockScope = "initial";
var functionScope = "initial";
function newFunctionScope() {
  let blockScope = "new";
  var functionScope = "new";
  console.log(`We see the ${blockScope} value`); // new
  console.log(`We see the ${functionScope} value`); // new
}
newFunctionScope();
console.log(`We now see the ${blockScope} value`); // old
console.log(`We now see the ${functionScope} value`); // old

Notice when we use the function, they both act the same! Both create new lexical scopes inside the function. In the first example, we used an if code block so only the let variable created a new lexical scope.

Part 2: Reassignment

What does it mean when we say reassign?

When we create a variable we first declare it, then initialize it with a value. If we change that value we are assigning it.

Taking a look at our table we know:

  • var & let ARE reassignable
  • const is a constant…

If we want to declare a const we must initialize it & declare it in the same place. After we do so we can’t change it.

  const bad; // Uncaught SyntaxError: Missing initializer in const declaration
  const good = "no errors here!";
  good = "error here.."; // TypeError: Assignment to constant variable.

This seems very simple - if we declare something constant it can never be changed, right? Not quite! Although const values cannot be reassigned this does not mean they are immutable! We can actually modify the properties of objects declared with const.

In other words, although our pointer stays the same, the value it points to can be changed.

Let’s try!

const myObj = {};
myObj.prop = 123;
console.log(myObj.prop); // 123
myObj = { possible: "Nope!" }; // TypeError - we can't change our initial  pointer

// If we want to make a prop we can freeze it!
const obj = Object.freeze({ innerObj: {} });
obj.prop = 123; // Now we can't change the object's properties
// ... buttttt
obj.innerObj.prop = "evil laugh"; // We CAN change the object's property's properties
console.log(obj);

As you can see, a const object is actually declaring the pointer as a constant, not the value it is pointing to!

In Javascript, arrays are objects (though they have a few key features) so this also applies to arrays!

const arr = ["you", "can", "change", "me"];
arr[1] = "did";
arr.push(":D");
console.log(arr); // [ 'you', 'did', 'change', 'me', ':D' ]

For those curious, the additional features array’s have are:

  1. an additional object called the Array.prototype in their prototype chain which contains “Array” methods (i.e. toString)
  2. length property (auto-updates)
  3. addition algorithm - runs if we set a new property to an array with a string name that can be converted to an integer number (i.e. ‘1’, ‘2’, ‘3’, etc.)

Part 3: Hoisting

So what is hoisting?

Hoisting is when javascript moves declarations of a var to the top of their scope AND sets it’s initial value to undefined (this will be explained in more detail in Part 5)

Our table tells us:

  • var does hoist
  • const & let do not hoist

What does that mean? var:

console.log(declaredBelow); // undefined
var declaredBelow;
console.log(declaredBelow); // undefined

let:

console.log(declaredBelow); // ReferenceError: declaredBelow is not defined
let declaredBelow;
console.log(declaredBelow); // undefined

As we can see let variables will throw an error when we try to use them before they are declared. Var, on the other hand, will hoist up this declaration to the top of its scope.

Javascript takes this,

console.log(declaredBelow); // undefined
var declaredBelow = "initialized!";

and reads this!

var declaredBelow;
console.log(declaredBelow); // undefined
declaredBelow = "initialized!";

This can cause issues by creating unexpected behavior, for example:

var badVar = "we want to see this";
function weirdBehaviour() {
  if (false) {
    var badVar = "this should not affect the outcome of our code";
  }
  console.log(badVar); // undefined
}
weirdBehaviour();

Because we never enter the if block, we would assume we would log “we want to see this”, but JavaScript hoists up the inner badVar declaration, reading this as:

var badVar = "we want to see this";
function weirdBehaviour() {
  var badVar;
  if (false) {
    badVar = "this should not affect the outcome of our code";
  }
  console.log(badVar); // undefined
}
weirdBehaviour();

To fix this weird behavior we can use let instead or var. Recall let will treat this if block as a lexical scope:

let goodVar = "we want to see this";
function goodBehaviour() {
  if (false) {
    let goodVar = "this should not affect the outcome of our code";
  }
  console.log(goodVar); // we want to see this"
}
goodBehaviour();

Let’s test our understanding with a more difficult example!

const varArrLogs = [];
for (var i = 0; i < 3; i++) {
  varArrLogs.push(() => {
    process.stdout.write(` ${i} `); // Notice this is a function!
  });
}
for (const log of varArrLogs) {
  log(); //  3  3  3
}
console.log();

Hopefully, looking at this now we can understand why this logs “3 3 3” instead of “1 2 3”. The var i in the for loop is hoisted up to the top of the scope, so when we run the process.stdout.write, it’s pulling the same variable.

If we switched to let or const, each iteration of the loop would create a new binding:

const letArrLogs = [];
for (let i = 0; i < 3; i++) {
  letArrLogs.push(() => {
    process.stdout.write(` ${i} `);
  });
}
for (const log of letArrLogs) {
  log(); // 0  1  2
}
console.log();

Modern programming generally avoids the use of classic for loops, in favor of loops such as forEach.

Part 4: Redeclaring

Redeclaring is, as one would guess, declaring an already existing variable in the same scope

Looking at our table again,

  • var can be redeclared
  • const & let cannot be redeclared

This one’s pretty easy to understand and the issues with variables being declarable are pretty obvious.

Take the following situation:

var res = "important data!";
var res = "whoops";
console.log(res); // whoops

By using let, we avoid accidentally reusing the same variables

let res = "important data!";
let res = "NOPE!"; // SyntaxError: Identifier 'res' has already been declared
console.log(res); // important data!
if (true) {
  let res =
    "Remember! a new scope means we can use the same name without losing our important data";
  console.log(res); // Remember! a new scope means we can use the same name without our important data
}
console.log(res); // important data!

Recap

Scope:

  • The region in which we can see/use a variable. Function scoped creates a new scope only when entering a new function, whereas block scoped creates a new scope whenever we enter a new block of code including ifs, loops, and functions.

Hoisting:

  • Moving the declaration of variables up to the top of their scope, and initializing them to undefined

Reassigned

  • Ability to assign a new value to a variable

Redeclared

  • Ability to declare variables with the same name, inside the same scope.

Taking one last (I promise) look at our table:

I hope it now provides a simple recap of the differences/similarities between var, let and const!

Loading...
Stephanie Mills

Stephanie Mills

Living to learn. Designing to disrupt. Coding to create

  • Learning. Laughing. Coding. Creating.