More or Less Const

JavaScript offers immutable bindings via const.

const a = 0
a = 1  // TypeError: Assignment to constant variable.

const can protect against accidental reassignment. Since most variables either don’t need to change value or shouldn’t, constancy should be preferred. Therefore, it would be simple to conclude that a thing called const would make sense as a JavaScript programmer’s “go-to” declaration keyword.

However, while some may swear by “const correctness,” the language still defaults to mutable bindings in a few common scenarios, leaving programs a lot less const-correct than const advocates might expect. Possible solutions involve using const in place of “nicer” language features.

Also, const does more than just create an immutable binding; the binding it creates is also block-scoped, and it also designates the code path leading up to itself a “temporal dead zone.” These extra features create new issues once const advocates fully commit to const ubiquity.

In light of prevalent existing mutable bindings and these extra features of const, we should reconsider whether it’s actually a good idea to try to use const everywhere, or even at all.

Constant functions

Function declarations are the usual syntax for creating procedures. However, they produce mutable bindings.

function a () {}
a = 0

If there is no reason to change a, then the binding should be constant. To accomplish that, one could instead create function expressions with const bindings.

const a = function () {}
a = 0  // TypeError

Function declarations have the advantage of being callable anywhere in their scope, whereas const bindings (including those for functions) must be defined before use; otherwise, the temporal dead zone created by const will cause an error to be thrown.

a()  // Works
function a () {}

b()  // ReferenceError: b is not defined
const b = function () {}

Referential leniency may not seem like a big advantage, because it’s not too difficult to reorganize code ensuring that functions are defined before use. Some style guides even require this (to the detriment of mutually-recursive functions).

Generally, functions declared with const can still be written in any order; since const “hoists” declarations, the following code still works as expected, due to b being defined before a is eventually called.

const a = function () { b() }
const b = function () {}
a()

Although, a could still be called before b is defined.

const a = function () { b() }
a()
const b = function () {}

Because of this, static analysis tools configured to detect the use of variables before their definition will likely always flag line 1 of either of the previous two examples, because the tool would need to consider how the code would evaluate in order to determine what was safe.

True, a static analysis tool could determine that the former of the previous two examples was definitely safe and the latter definitely not. But it could not make such determinations consistently, because some code (like in the following example) might or might not throw an error.

const a = function () { if (Math.random() >= .5) b() }
a()
const b = function () {}

For this reason, I doubt evaluation will ever be used to determine safeness. I think linter authors will simply be pessimistic about safeness.

There is another option, however. The code quality analysis tool ESLint can be configured to disallow assignment to function declarations via the rule “no-func-assign”. The original example could have been flagged.

function a () {}
a = 0

ESLint reports:

2:1  error  'a' is a function  no-func-assign

Using ESLint, one could continue using function declaration syntax, and also not to have to worry about the order of his functions, while still receiving warnings whenever he reassigns his function bindings.

A programmer might have the best of both worlds by combining parse-time definition with static analysis.

Constant parameters

Parameters are mutable.

function a (b, c) {
  b = 0
  c = 1
}

In my experience, the only especially useful aspect of mutable parameters was the opportunity to assign “default values” to them.

function a (options) {
  options = options === undefined ? {} : options
  …
}

There is now syntax to specify “default values,” so that point is moot.

function a (options = {}) {
  …
}

The default mutability of function parameters should irk const advocates. If one uses const to create bindings elsewhere, then, as a matter of consistency, shouldn’t he want his parameters to be constant, too? Unfortunately, the language doesn’t yet offer an obvious feature to guarantee that.

One (albeit repetitive) solution could be to “re-create” const bindings for parameters.

function a (_b, _c) {
  const b = _b
  const c = _c
}

But there are still mutable bindings for _b and _c, so it’s still technically possible to reassign and even use them. (And as Murphy’s Law states, that will happen.) Then the purpose of const is defeated.

To avoid introducing temporary mutables into the scope, there is arguments.

function a () {
  const b = arguments[0]
  const c = arguments[1]
}

Array destructuring can simplify declaration and emulate the rest parameter. “Parameters” could all have immutable bindings by moving their definitions into the function body.

function a () {
  const [b, c, ...rest] = arguments
}

Also, while parameter syntax now permits object destructuring, those bindings are also mutable. Also, the destructured object doesn’t even get a binding.

function a ({b, c} = {}) {
  b = 0
  c = 1
}

const declarations support object destructuring as well. The discussed techniques could be combined to achieve constancy and flexibility in an, erm, “expressive” manner.

function a () {
  const [options = {}] = arguments
  const {b, c} = options
  d(options)
}

This technique nets more constant bindings, but it is less “pretty” (i.e. concise, familiar, idiomatic…) than using real parameters, which will probably sway most programmers to reject it… even the ostensible const vanguards. There are also a few practical issues with “fake parameters:”

Are “prettiness” and reflection capabilities worth trading for additional constant bindings? Ideally, the language designers will increase support for constants, and a trade-off won’t be necessary.

But again, ESLint has our back: There is a rule to disallow the reassignment of function parameters (“no-param-reassign”).

function a (b, c) {
  b = 0
  c = 1
}

ESLint reports:

2:3  error  Assignment to function parameter 'b'  no-param-reassign
3:3  error  Assignment to function parameter 'c'  no-param-reassign

Maybe it’s not so important that programs throw errors at runtime, when our linters can report the same errors before runtime.

Possible Language Solutions

If the following syntaxes were available to create immutable bindings for functions and parameters, then many of the aforementioned issues regarding mutability could be resolved more concisely.

const function a (const b, {const c, const d} = {}) {}

However, it is debatable whether a “const function declaration” should create a temporal dead zone for its binding, like const does for variables. I hope that it wouldn’t, but if it did, then parse-time definition would no longer be an advantage of the function declaration syntax.

Also, prefixing declarations with const where no declaration keyword was used before is still less concise than the alternative.

function a (b, {c, d} = {}) {}

C and C++ programmers may be accustomed to litanies of const keywords in their programs, but I expect such a feature would be received with half-hearted embrace by JavaScript programmers. Until its introduction, they would have been unburdened by the choice to use explicit constant adjectives everywhere… and “for little gain,” as I expect many would say before dismissing such an option.

const problems

Many programmers have already adopted const as their primary declaration keyword. They might want to reconsider: const can cause trouble.

Once const is declared, it can’t be assigned-to, which makes it difficult to create a “conditional constant.” (In fact, const can’t even be declared at all without providing an initializer.)

const a  // SyntaxError: Missing initializer in const declaration
const a = undefined  // Works
if (…) {
  a = 0  // TypeError: Assignment to constant variable.
} else {
  a = 1
}

The constant can’t be declared inside a block and used later, since const creates block-scoped bindings.

if (…) {
  const a = 0
} else {
  const a = 1
}
a  // ReferenceError: a is not defined

What to do? In many cases, the conditional (ternary) operator suffices.

const a = … ? 0 : 1

But the conditional operator can only contain expressions, which might lead programmers to write “tricky” code in order to leverage it. For this reason, the operator is often used sparingly. The inability to use statements can also make certain code impossible to write in this language: e.g., conditionally creating intermediate values, or placing breakpoints with debugger statements.

At my company, we usually end up “regressing to let.”

let a
if (…) {
  a = 0
} else {
  a = 1
}

So much for “a dedication to const correctness.” Now, even with a linter, future assignments to the otherwise-constant binding of a won’t be flagged.

Another option is to return from an immediately-invoked function expression.

const a = (function () {
  if (…) {
    return 0
  } else {
    return 1
  }
}())

But it’s harder to abruptly exit from the outer function when using IIFE’s.

const [a, abruptlyExit] = (function () {
  if (…) {
    return [, true]
  } else if (…) {
    return [0]
  } else {
    return [1]
  }
}())
if (abruptlyExit) {
  return
}

One might pose, “It’s best to use const most of the time, but don’t sweat it if the language won’t let you.” However, I don’t think we should let a language’s shortcomings dictate an inconsistent, arguably-hypocritical coding style.

On the other hand, var is function-scoped, making it more useful for conditional declaration.

if (…) {
  var a = 0
} else {
  var a = 1
}
a  // No error.

Now, imagine if there were an ESLint rule called “no-var-reassign”, which treated all var bindings like constants. This rule could make var more useful than const, by pairing the function scoping of var with pre-runtime error reporting.

if (…) {
  var a = 0
} else {
  var a = 1
}
a  // No error.
a = 2  // ESLint could report an error here.

If reassignment was still necessary or desirable for some reason, then, considering the manner in which let serves as a “constant escape hatch,” disabling the ESLint rule on a case-by-case basis could serve the same purpose.

if (…) {
  var a = 0
} else {
  var a = 1
}
a = 2  // eslint-disable-line no-var-reassign

Also, there is no need to consider such a rule in theoretical terms merely. I’ve created a package providing such a rule, which you can install via NPM: eslint-plugin-no-var-reassign.

I think static analysis may ultimately be our saving grace. Who needs const? var is the new const.

Observations

JavaScript’s support for constants is currently lacking in common scenarios, and the nature of the bindings that are available can make them difficult to use. The missing features can be emulated, but the resulting code is “weird” and can have drawbacks in unpredictable situations. Meanwhile, a pragmatist already has all the tools he needs to enforce constancy.

Because of the preceding cases, I believe that const cannot be used both practically and consistently without a detrimental effect on code quality. Since mutable var bindings paired with a linter treating them as immutable can be a more effective alternative, I consider const an anti-pattern.

In the latest version of the language, I recommend using function declarations, var, and static analysis instead of const.