const is the new var
By Delicious Insights • Published on May 16, 2020

This post is also available in French.

In this thirteenth installment of our daily series “19 nuggets of vanilla JS,” we broach a controversial topic: the respective roles of the three generic declarative keywords: var, let and const (as there is no question about their two friends, function and class).

The series of 19

Check out surrounding posts from the series:

  1. Extracting emojis from a text
  2. Properly defining optional named parameters
  3. const is the new var (this post)
  4. Using named captures
  5. Object spread vs. Object.assign
  6. …and beyond! (fear not, all 19 are scheduled already)…

Before / after ES2015

Before ES2015, JavaScript only had two declarative keywords: var and function. Besides, the unit of scope wasn’t the block (as delimited by a pair of curly braces), unlike many other languages, but the function (or lacking one, the global scope).

This can catch you off-guard when coming from other languages; and it is never good for a programming language to behave in surprising ways: this is an infinite source of bugs.

As a result, ES2015’s three new declarative keywords (let, const and class) all use the current block as their scope, which is way more aligned with the dominant paradigm in programming. Their identifiers only exist between the declaration line and the end of the block (even implicit blocks, like curly-less loops).

The hoisting of var: an antipattern

It also happens that the two historical keywords are hoisted: this means that the JavaScript interpreter will act as if their declarations (but not their initializations, if any) happened at the top of the scope, regardless of their actual source position. To be more precise, var declarations get hoisted first, then function ones.

It was conceived as an attempt at preemptive optimization in the very first implementation of the engine, and doesn’t belong in 2020, or 2015, or even 2009. It is actually sneaky. Check this out:

function wtf() {
  console.log(foo)
  var foo = 42
  console.log(foo)
}

wtf() // => logs 'undefined' then '42'

This is as counter-intuitive as it gets. By rights, that code shouldn’t even be syntactically valid. Failing that, it should at least throw a ReferenceError on the first line of the function. But it doesn't! Because of hoisting, the code that is actually run looks like this:

function wtf() {
  var foo
  console.log(foo)
  foo = 42
  console.log(foo)
}

There is zero benefit to hoisting variables. None. Zero. Zilch. In fact, the consensus today is that var doesn’t belong in modern (ES2015+) JS code.

At a minimum, it should therefore be replaced by let, that has two benefits:

  • It is not hoisted, so any attempt at referencing it ahead of its declaration will throw a ReferenceError, as you would expect.
  • Its scope is the current block, which again aligns better with common expectations.

But then, why is this post about const instead of let?

Reassigment is a rare beast

There is exactly one difference between let and const: the latter doesn’t allow reassigning. A const declaration must be initialized on-the-fly, and cannot be reassigned (e.g. with =) later.

Why prefer const?

In practice, the overwhelming majority of declarations are never reassigned; the reason is simple: altering the semantics of an identifier along the code creates confusion (reducing maintainability) and fosters bugs. Sometimes a reassignment is perfectly justified (e.g. async initialization, progressive refinement of a piece of data) but this remains a tiny minority case.

On the other hand, it often happens that we reassign by mistake. Why? Mostly due to sloppy copy-pastes or a clumsy code completion.

function oops() {
  let superComplexStrut = 42
  let superComplexStuff = bigCompute()
  if (someCondition) {
    superComplexStrut *= 2 // Oops, I meant "…Stuff"
  }
  // …
}

By going with const by default, we’re immediately held back by the collar when trying to reassign an identifier by mistake. If ESLInt is properly configured (see later), it will spot this immediately (within seconds of typing the code, or at commit time with relevant hooks set up). At worst, we’ll get a runtime error (if our JS runtime understands const anyway).

function oops() {
  const superComplexStrut = 42
  let superComplexStuff = bigCompute()
  if (someCondition) {
    // ESLint + TypeError: Assignment to constant variable.
    superComplexStrut *= 2
  }
  // …
}

There is this tendency to stick with let in numerical for loops, but numerical for loops should be super-rare now that we have the excellent for…of loop (spoiler alert: a later nugget will cover this in-depth). No reassignment (such as i++)? No need for let!

// 🔴 BLEH
for (let i = 0, len = arr.length; i < len; i++) {
  const item = arr[i]
  // …
}

// ✅ Yeah!
for (const item of arr) {
  // …
}

Beware of parameters

In a function, signature parameters are not declared with let or const , so stay sharp.

const ≠ immuable

Another critical point is: not reassignable does not mean immutable. In particular, if the identifier references an object, it’s still perfectly possible to alter the contents of it. You just can't reassign the identifier.

const obj = { name: "John" }
obj.name = "Jane" // Perfectly possible
obj = { name: "Jean-Gandalf" } // Perfectly impossible

If you wish to “freeze” an object, there are multiple levels you can go for; at top-level, the standard library ever since ES5 features Object.preventExtensions(), Object.seal() and Object.freeze(), in increasing locking order. For recursive freezing, you’ll need to look for utility libraries such as deep-freeze-strict. But you can do it.

(On a side note, if you follow functional programming principles of immutability, you should not even need to do any of this.)

ESLint rules

Our beloved ESLint features several rules on this topic:

  • no-var barks on any use of var
  • prefer-const spots any let or var declaration that is never reassigned and requires a switch to const
  • no-const-assign detects reassignments on a const-declared identifier; this is because, should your code be transpiled down to ES5, or run by an engine that doesn’t enforce const semantics (such as IE11’s), it would be runnable: the linter is your only safeguard then. This setting is part of the recommended preset, by the way: eslint:recommended.

Looking for other perspectives?

I said this is a controversial topic: you’ll find many people that think that const should be reserved for “absolute” constants, not for processing steps in, say, a function’s body. They believe const is not compatible with the concept of a variable, forgetting that in the vast majority of our codes, so-called variables… do not vary.

Still, it’s always a good idea to contrast several viewpoints when shaping your own opinion. The always relevant Dan Abramov wrote in 2019 on this topic, and I recommend you read what he had to say.

Want to dive deeper?

Our trainings are amazeballs, be they in-room or remote online, multi-client or in-house just for your company!

Check out our video course: JavaScript: this is it! 🖥

Get in-depth understanding of how this works in JavaScript, from core ground rules to API overrides to arrow functions, binding and much, much more!