ponyfoo.com

Let’s use const! Here’s why.

Fix
A relevant ad will be displayed here soon. These ads help pay for my hosting.
Please consider disabling your ad blocker on Pony Foo. These ads help pay for my hosting.
You can support Pony Foo directly through Patreon or via PayPal.

When developing software, most of our time is spent reading code. ES6 offers let and const as new flavors of variable declaration, and part of the value in these statements is that they can signal how a variable is used. When reading a piece of code, others can take cues from these signals in order to better understand what we did. Cues like these are crucial to reducing the amount of time someone spends interpreting what a piece of code does, and as such we should try and leverage them whenever possible.

A let statement indicates that a variable can’t be used before its declaration, due to the Temporal Dead Zone rule. This isn’t a convention, it is a fact: if we tried accessing the variable before its declaration statement was reached, the program would fail. These statements are block-scoped and not function-scoped; this means we need to read less code in order to fully grasp how a let variable is used.

The const statement is block-scoped as well, and it follows TDZ semantics too. The upside is that const bindings can only be assigned during declaration.

Note that this means that the variable binding can’t change, but it doesn’t mean that the value itself is immutable or constant in any way. A const binding that references an object can’t later reference a different value, but the underlying object can indeed mutate.

In addition to the signals offered by let, the const keyword indicates that a variable binding can’t be reassigned. This is a strong signal. You know what the value is going to be; you know that the binding can’t be accessed outside of its immediately containing block, due to block scoping; and you know that the binding is never accessed before declaration, because of TDZ semantics.

You know all of this just by reading the const declaration statement and without scanning for other references to that variable.

Constraints such as those offered by let and const are a powerful way of making code easier to understand. Try to accrue as many of these constraints as possible in the code you write. The more declarative constraints that limit what a piece of code could mean, the easier and faster it is for humans to read, parse, and understand a piece of code in the future.

Granted, there’s more rules to a const declaration than to a var declaration: block-scoped, TDZ, assign at declaration, no reassignment. Whereas var statements only signal function scoping. Rule-counting, however, doesn’t offer a lot of insight. It is better to weigh these rules in terms of complexity: does the rule add or subtract complexity? In the case of const, block scoping means a narrower scope than function scoping, TDZ means that we don’t need to scan the scope backwards from the declaration in order to spot usage before declaration, and assignment rules mean that the binding will always preserve the same reference.

The more constrained statements are, the simpler a piece of code becomes. As we add constraints to what a statement might mean, code becomes less unpredictable. This is one of the biggest reasons why statically typed programs are generally easier to read than dynamically typed ones. Static typing places a big constraint on the program writer, but it also places a big constraint on how the program can be interpreted, making its code easier to understand.

With these arguments in mind, it is recommended that you use const where possible, as it’s the statement that gives us the least possibilities to think about.

if (condition) {
  // can't access `isReady` before declaration is reached
  const isReady = true
  // `isReady` binding can't be reassigned
}
// can't access `isReady` outside of its containing block scope

When const isn’t an option, because the variable needs to be reassigned later, we may resort to a let statement. Using let carries all the benefits of const, except that the variable can be reassigned. This may be necessary in order to increment a counter, flip a boolean flag, or to defer initialization.

Consider the following example, where we take a number of megabytes and return a string such as 1.2 GB. We’re using let, as the values need to change if a condition is met.

function prettySize (input) {
  let value = input
  let unit = `MB`
  if (value >= 1024) {
    value /= 1024
    unit = `GB`
  }
  if (value >= 1024) {
    value /= 1024
    unit = `TB`
  }
  return `${ value.toFixed(1) } ${ unit }`
}

Adding support for petabytes would involve a new if branch before the return statement.

if (value >= 1024) {
  value /= 1024
  unit = `PB`
}

If we were looking to make prettySize easier to extend with new units, we could consider implementing a toLargestUnit function that computes the unit and value for any given input and its current unit. We could then consume toLargestUnit in prettySize to return the formatted string.

The following code snippet implements such a function. It relies on a list of supported units instead of using a new branch for each unit. When the input value is at least 1024 and there’s larger units, we divide the input by 1024 and move to the next unit. Then we call toLargestUnit with the updated values, which will continue recursively reducing the value until it’s small enough or we reach the largest unit.

function toLargestUnit (value, unit = `MB`) {
  const units = [`MB`, `GB`, `TB`]
  const i = units.indexOf(unit)
  const nextUnit = units[i + 1]
  if (value >= 1024 && nextUnit) {
    return toLargestUnit(value / 1024, nextUnit)
  }
  return { value, unit }
}

Introducing petabyte support used to involve a new if branch and repeating logic, but now it’s only a matter of adding the PB string at the end of the units array.

The prettySize function becomes concerned only with how to display the string, as it can offload its calculations to the toLargestUnit function. This separation of concerns is also instrumental in producing more readable code.

function prettySize (input) {
  const { value, unit } = toLargestUnit(input)
  return `${ value.toFixed(1) } ${ unit }`
}

Whenever a piece of code has variables that need to be reassigned, we should spend a few minutes thinking about whether there’s a better pattern that could resolve the same problem without reassignment. This is not always possible, but it can be accomplished most of the time.

Once you’ve arrived at a different solution, compare it to what you used to have. Make sure that code readability has actually improved and that the implementation is still correct. Unit tests can be instrumental in this regard, as they’ll ensure you don’t run into the same shortcomings twice. If the refactored piece of code seems worse in terms of readability or extensibility, carefully consider going back to the previous solution.

Consider the following contrived example, where we use array concatenation to generate the result array. Here, too, we could change from let to const by making a simple adjustment.

function makeCollection (size) {
  let result = []
  if (size > 0) {
    result = result.concat([1, 2])
  }
  if (size > 1) {
    result = result.concat([3, 4])
  }
  if (size > 2) {
    result = result.concat([5, 6])
  }
  return result
}
makeCollection(0) // <- []
makeCollection(1) // <- [1, 2]
makeCollection(2) // <- [1, 2, 3, 4]
makeCollection(3) // <- [1, 2, 3, 4, 5, 6]

We can replace the reassignment operations with Array#push, which accepts multiple values. If we had a dynamic list, we could use the spread operator to push as many ...items as necessary.

function makeCollection (size) {
  const result = []
  if (size > 0) {
    result.push(1, 2)
  }
  if (size > 1) {
    result.push(3, 4)
  }
  if (size > 2) {
    result.push(5, 6)
  }
  return result
}
makeCollection(0) // <- []
makeCollection(1) // <- [1, 2]
makeCollection(2) // <- [1, 2, 3, 4]
makeCollection(3) // <- [1, 2, 3, 4, 5, 6]

When you do need to use Array#concat, you should probably use [...result, 1, 2] instead, to keep it simpler.

The last case we’ll cover is one of refactoring. Sometimes, we write code like the next snippet, usually in the context of a larger function.

let completionText = `in progress`
if (completionPercent >= 85) {
  completionText = `almost done`
} else if (completionPercent >= 70) {
  completionText = `reticulating splines`
}

In these cases, it makes sense to extract the logic into a pure function. This way we avoid the initialization complexity near the top of the larger function, while clustering all the logic about computing the completion text in one place.

The following piece of code shows how we could extract the completion text logic into its own function. We can then move getCompletionText out of the way, making the code more linear in terms of readability.

const completionText = getCompletionText(completionPercent)
// ...
function getCompletionText(progress) {
  if (progress >= 85) {
    return `almost done`
  }
  if (progress >= 70) {
    return `reticulating splines`
  }
  return `in progress`
}

What’s your stance in const vs. let vs. var?

This article was extracted from Practical ES6, a book I’m writing. It’s openly available online under HTML format, and on GitHub as AsciiDoc. It recently raised over $12,000 💰 in funding on Indiegogo and is available as an Early Release by the publisher, O’Reilly Media.

Liked the article? Subscribe below to get an email when new articles come out! Also, follow @ponyfoo on Twitter and @ponyfoo on Facebook.
One-click unsubscribe, anytime. Learn more.

Comments (21)

Brian wrote

In current JS engines (I’m thinking V8 and SpiderMonkey - haven’t looked at Chakra), using let and const in a function will often drop a function out of optimization, while var won’t. There are long-open bugs about this in each engine.

Until that’s fixed up, I’m just going to keep using var for everything.

anon wrote

Is it even possible to optimize them? Block-scoping is essentially a hack via try/catch.

We are all happily transpiling and using async/await for simple AJAX requests.

It feels like we might be in for a disappointment.

Jacob Groß wrote

I’m all on Brian’s side. There are some scenarios where I rather use let or const (setTimeout in loops for example), but other than that var is my way to go.

Alexey Ukolov wrote

I’ve been thinking about this very thing recently. And the biggest issue I have with const is it’s semantics - the difference between “value won’t change” (which is what most of the people think about, when somebody says “it’s a constant”) and “binding won’t change” is important, but obscured behind the wrong name (I can’t suggest a better one, though). It’s just another one of those “things you just have to remember about javascript” and in that sense it’s no better than var.

Having said that - I do use const whenever possible with a fallback to let.

Angelo Michel wrote

I am using const a lot. There are almost no situations where I need anything else.

There are however ‘two types’ of usages for me. All my variables are defined likeThis (in camelCase) in my normal ES6 JavaScript. However, I usually have a constants.js in my project where in you will find almost never changing values like const MAX_NUMBER_OF_BUCKETS = 5 (or anything similar).

EECOLOR wrote

No need for var anymore, const and let are enough. Prefer const but don’t be religious about it. Some code is more readable when using let. I tend to move code using let to it’s own function.

Decho Iliev wrote

I have a problem(error in code) when use let in Safari before, and maybe in FF.

Caio Ribeiro Pereira wrote

What is the benefits about using const? Is it consume less memory or cpu?

Nicolás Bevacqua wrote

I suggest you read the article and find out! 😂

Caio Ribeiro Pereira wrote

Sorry, I didn’t ask well, my question was about performance, like benchmark: let vs const how many memory both consume or how many cpu both uses. Because I read the article and there is nothing about performance.

dzek wrote

Because const is not about performance.

There’s a comment above suggesting that const/let are slower than var, because using them puts whole function out of some optimization. But I guess it won’t matter in most use cases.

Elco Klingen wrote

I use const whenever possible, and let when the value needs to be changed, yet the code is too small or specific to make it an abstract.

I use var only in projects where the browserscope is large and babel is unavailable.

My main reason for using const is that it keeps you from making simple mistakes that result in hard to debug bugs because values are mutated when they shouldn’t be.

Everybody has a bad day now and then, so catching these errors early in the process is crucial.

The actual name const is a big misnomer. They are not constants, they are binding-locked variables. But then again, ES6 has more stuff like that (classes anyone?). That’s life in a JavaScript world. 😅

Nicolás Bevacqua wrote

For what it’s worth — technically — the binding is constant. That said, most people assume the statement should be about the value, and not the binding, which is why some consider const to be confusing 😅

Elco Klingen wrote

To add to my earlier reply: even though nearly all instances of let can be refactorer to use const instead, it doesn’t always make the code more readable. To me, readability is one of the more important aspects of code. It allows both you at a later date and other developers to dive in with enough confidence and velocity to prevent bugs due to a lesser understanding of the codebase. Even if that means that sometimes you use let, you repeat some code or you explicitly don’t use the most compiler optimized code you could have.

Jochen H. Schmidt wrote

No const isn’t a misnomer. It just shows how many coders still have this imagination that a variable is something about mutating some value. A variable is just the binding of a name to a value. It is that construct we talk about: name bindings. There are variable name bindings and constant name bindings. This has nothing to do with value immutability.

Mutating an object is for example assigning a new value to one of its properties. There is no binding involved.

When I first read about const and let I actually was a bit disappointed because I immediately knew that const will be the much more useful name binding. I would have preferred e.g something like:

let a = 4 // constant name binding let! counter = 0 // variable name binding

Omar wrote

The keyword const is counter to the its behaviour in other languages, such as c/c++. In c/c++, using const means the reference is locked in addition to the values:

const struct {
    int a;
} myStruct = {10};
myStruct.a = 5; //Compile error
Noah Rodenbeek wrote

“When reading a piece of code, others can take cues from these signals in order to better understand what we did.”

My thing is, if everything is a constant, how do we know what is constant? A const Array or Object isn’t constant at all. They’re constantly inconstant.

var API_URL = 'api/'; // all-caps, years of training has taught you this is a constant
const apiUrl = 'api/'; // I guess as long as everything's a constant, then this has to be a constant too

The way I’d prefer we proliferate this new variable is:

const API_URL = 'api/'; // not only is it a constant, it will break if you try to unconstant it

My other thing is, var still exists and because of its strong brand recognition, it’s the clearest way of communicating variable initialization. let is for those times you’re wanting something temporary, you want to let it exist but not gum up the declaration hunk at the top of a function.

let/const aren’t block scoped. They’re line scoped. Technically, they’re function scoped/hoisted, but they don’t initialize until the line of their assignment.

for (let x = 0; x < arr.length; x++) {
    console.log('beginning of block scope', foo); // super error
    let foo = 'now I exist';
}
Nicolás Bevacqua wrote

You’re conflating constant variables (the value can’t change) and constant bindings (the binding can’t change). See this article for a disambiguation. You can use READ_ONLY for const values that are meant as constant variables.

“Brand recognition” is a poor reason why to stick to something. Language features being new isn’t any more of a good reason either. The focus should be on the signals that pieces of code produce. const signals that the developer couldn’t change the binding to something else later, so there’s no need to scan the code for potential changes.

Noah Rodenbeek wrote

I mean to illustrate how inherently conflatable the two different concepts are. I understand what’s going on behind the scenes, but junior devs that are wholesale replacing their variable declarations don’t. It’s why we have to write so many articles explaining what immutable means and how to add Object.freeze code to account for the inability to track scope.

I think we’re after the same thing here, just taking different approaches. I just wanted to say that before adding another counter thought so you don’t take this as combative. This whole var/let/const thing has been a huge source of curiosity for me and I still haven’t found a satisfying explanation or approach.

That said, I want to respond to “so there’s no need to scan the code for potential changes” because that’s actually a valid concern for all levels of dev in real work environments. Either a project ran away from you or you’re dealing with legacy code, either way it’s hard to fly around and see what variable is meant to be what type. I would counter that this is a symptom of bad code structure. Too much functionality in a single function or too many smaller functions relying on global variables. Both instances are patterns I would prefer to educate out of a junior dev than reenforce with a line scoped variable declaration.

Thanks, Nicolas. I am kind of obsessed with this topic, and it’s difficult to find devs that actually want to talk it out.

Nicolás Bevacqua wrote

It’s definitely hard to strike the right design patterns around this topic. Like you say, it ultimately comes down to the experience across the team so it’s always going to be at least slightly different across companies and even across teams in the same company. I’ve been working on a book called Mastering Modular JavaScript which addresses these kinds of concerns, but it can be hard to work out a set of practices a heterogeneous team can follow without damaging productivity or confusing junior developers.

When it comes to things that can be handed off to a linter, my advice would be to either adopt a set of lint rules like standard or whatever else you might like at any one point in time, and then just enforce it and forget about it. Things like deciding on what variable binding should be preferred most of the time probably don’t matter as much with less experienced teams, but it can help when the team is comfortable with modern JavaScript.

Noah Rodenbeek wrote

I believe we’ve just turned stalemate to compromise. Excellent discussion, thank you for batting that around with me

Excited for the new book too, this is the first I’ve heard of it!