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:”
Function.length
will be 0. While inspecting the property doesn’t guarantee anything about the actual behavior of a function, some libraries (like Express) do check it, which could lead to confusing misbehavior.Function.prototype.toString
won’t produce a meaningful representation of the function’s parameters. While this method could already be unreliable due to insufficiently configured minifiers, some libraries (like Angular) do utilize it.
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
.