Disclaimer: JavaScript the language has some complicated edge cases, and as such, the following essay has some hand-wavey bits and some bits that are usually correct but wrong for certain edge cases. If it helps any, pretend that nearly every statement has a footnote reading, “for most cases in practice, however ______.”
ECMAScript-2015 gives us three different variable declaration statements: var
, let
, and const
. Language features are interesting, but they aren’t free: Every feature we use in a program increases its surface area, and the additional complexity of the tool should be justified by the simplification it brings to the program.
We already had var
. What value do let
and const
confer? And is that value enough to justify their use?
One way to answer that question is to perform a thought experiment:
Take a function using one of these features, and convert it to an equivalent function that doesn’t use the feature. We can compare the two versions and see how much code and accidental complexity is added to replace the feature with code that repolicates the feature’s semantics.
is var necessary?
Let’s try this with var
. Is var
really necessary in function scope? Can we write JavaScript without it? And let’s make it interesting: Can we get rid of var
without using let
?
Variables declared with var
have exactly the same scope as function arguments. So, one strategy for removing var
from functions is to replace declared variables with function arguments.
So:
function callFirst (fn, larg) {
return function () {
var args = Array.prototype.slice.call(arguments, 0);
return fn.apply(this, [larg].concat(args))
}
}
Would become:
function callFirstWithoutVar (fn, larg) {
return function (args) {
args = Array.prototype.slice.call(arguments, 0);
return fn.apply(this, [larg].concat(args))
}
}
We can manually hoist any var
that doesn’t appear at the top of the function, so:
function repeat (num, fn) {
var i;
for (i = 1; i <= num; ++i)
var value = fn(i);
return value;
}
Would become:
function repeat (num, fn, i, value) {
i = value = undefined;
for (i = 1; i <= num; ++i)
value = fn(i);
return value;
}
There are a few flaws with this approach, most significantly that the code we write is misleading to human readers: It clutters the function’s signature with its local variables.1 Fortunately, there’s a fix: We can wrap function bodies in IIFEs2 and give the IIFEs the extra parameters. Like this:
function repeat (num, fn) {
return ((i, value) => {
for (i = 1; i <= num; ++i)
value = fn(i);
return value;
})();
}
Now our function has its original signature, and we have the expected behaviour. The flaw with this approach, of course, is that our function is more complicated both in code and behaviour: There’s this confusing return ((i, value) => {
and })();
stuff going on, and even though we all love the techniques espoused in JavaScript Allongé, this appears a bit gratuitous.
And at runtime, we are creating an extra closure with every invocation. This has performance implications, memory implications, and it certainly isn’t doing our stack traces any favours.
But we get the general idea: If we were willing to live with this code, we could get rid of a lot or even all uses of var
from our programs. Now, what about let
?
is let necessary?
What if we wanted to remove let
and just program with var
? Or perhaps remove let
altogether? Can it be done?
let
has a more complicated behaviour, but if we are careful, we can translate let
declarations into IIFEs that use var
. And of course, if we want to remove let
altoogether, if we can translate let
into var
, and we can remove var
altogether,w e can remove let
altogether as well.
The simplest case is when a let
is at the top-level of a function. In that case, we can replace it with a var
.3 And from there, if we are removing both let
and var
, we can excise it completely.
So:
function arraySum (array) {
let done,
sum = 0,
i = 0;
while ((done = i == array.length, !done)) {
sum += array[i++];
}
return sum
}
Would become:
function arraySum (array) {
var done,
sum = 0,
i = 0;
while ((done = i == array.length, !done)) {
sum += array[i++];
}
return sum
}
And then:
function arraySum (array) {
return ((done, sum, i) => {
sum = i = 0;
while ((done = i == array.length, !done)) {
sum += array[i++];
}
return sum
})();
}
That works.4
Now what about let
inside a block? This is, after all, it’s claim to fame. The least complicated case is when the body of the block does not contain a return
. In that case, we use the same IIFE technique, but don’t return anything. So this variation:
function arraySum (array) {
let done,
sum = 0,
i = 0;
while ((done = i == array.length, !done)) {
let value = array[i++]
sum += value;
}
return sum
}
Would become:
function arraySum (array) {
var done,
sum = 0,
i = 0;
while ((done = i == array.length, !done)) {
(() => {
var value = array[i++];
sum += value;
})();
}
return sum
}
By the way, the performance is worse than rubbish, because we’re creating and discarding our IIFE on every trip through the loop. In cases, like this, we can avoid a lot of that by cleverly “hoisting” the IIFE out of the loop:
function arraySum (array) {
var done,
sum = 0,
i = 0,
__closure = () => {
var value = array[i++];
sum += value;
};
while ((done = i == array.length, !done)) __closure();
return sum
}
loops and blocks
let
has special rules for loops. So if we simplify our arraySum
with a for...in
loop, we’ll need an IIFE around the for
loop to prevent any let
within the loop from leaking into the surrounding scope, and one inside the for
loop to preserve its value within the block. Let’s write a completely contrived function:
function sumFrom (original, i) {
let sum = 0,
array = original.slice(i);
for (let i in array) {
sum += array[i];
}
return `The sum of the numbers ${original.join(', ')} from ${i} is ${sum}`
}
This can be rewritten as:
function sumFrom (original, i) {
var sum = 0,
array = original.slice(i),
__closure = (i) => sum += array[i];;
(() => {
var i;
for (i in array) __closure(i);
})();
return `The sum of the numbers ${original.join(', ')} from ${i} is ${sum}`
}
Some blocks contain a return
, and that returns from the nearest enclosing function. But if we replace the block with an IIFE, the return
will return to the IIFE. When the IIFE surrounds the entire body of the function, we can just return whatever the IIFE returns, as we do above. But when the IIFE represents a block within the body of the function, we can only return the value of the block if it returns something.
So something like this:
function maybe (fn) {
return function (...args) {
for (let arg of args) {
if (arg == null) return null;
}
return fn.apply(this, args)
}
}
Becomes this:
function maybe (fn) {
return function (...args) {
var __iife_returns,
__closure = (arg) => {
if (arg == null) return null;
};
__iife_returns = (() => {
var arg, __closure_returns;
for (arg of args) {
__closure_returns = __closure(arg);
if (__closure_returns !== undefined) return __closure_returns;
}
})();
if (__iife_returns !== undefined) return __iife_returns;
return fn.apply(this, args)
}
}
We’ll leave it as “an exercise for the reader” to sort out how to handle a return
that doesn’t return anything:
function maybe (fn) {
return function (...args) {
for (let arg of args) {
if (arg == null) return;
}
return fn.apply(this, args)
}
}
Or a return when we don’t know what we are returning:
function maybe (fn) {
return function (...args) {
for (let arg of args) {
if (arg == null) return arg;
}
return fn.apply(this, args)
}
}
what have we learnt from removing var and let?
The first thing we’ve learnt is that for most purposes, var
and let
aren’t strictly necessary in JavaScript. Roughly speaking, scoping constructs with lexical scope can be mechanically transformed into functional arguments.
This is not news, it’s how let
was originally written in the Scheme flavour of Lisp, and it’s how do
works in CoffeeScript to provide let-like behaviour.
So one argument is, we could strip these out of the language to provide a more minimal set of features. Or we could just use var
, and translate all let
s to var
.
However, looking at the code we would have to write if we didn’t have var
, or if we had to write let
without var
, it’s clear that while a language without let
would be smaller, the programs we write in it would be larger.
This is a case where taking something away does not create elegance. If we take let
away and only use var
, we have to add IIFEs to get block scope. If we take var
away too, we get even more IIFEs. Removing let
makes our programs less elegant.
wait, what about const?
As you know, const
behaves exactly like let
, however when a program is first parsed, it is analyzed, and if there are any lines of code that attempt to assign to a const
variable, an error is generated. This happens before the program is executed, it’s a syntax error, not a runtime error.
Presuming that it compiles correctly and you haven’t attempted to rebind a const
name, const
is exactly the same as let
at runtime. Therefore, removing const
from a working program is as simple as replacing it with let
. So the following:
function sumFrom (original, i) {
let sum = 0;
const array = original.slice(i);
for (let i in array) {
sum += array[i];
}
return `The sum of the numbers ${original.join(', ')} from ${i} is ${sum}`
}
Can be translated to:
function sumFrom (original, i) {
let sum = 0,
array = original.slice(i);
for (let i in array) {
sum += array[i];
}
return `The sum of the numbers ${original.join(', ')} from ${i} is ${sum}`
}
one of these things is not like the others
As we can see, const
is not like var
or let
. Removing var
by changing it into parameters involves the creation of additional IIFEs, cluttering the code and changing the runtime behaviour. Removing let
adds much more complexity again. But removing const
by changing it into let
is benign. It doesn’t add any complexity to the code or the runtime behaviour.
This is not surprising: const
isn’t a scoping construct, it’s a typing construct. It exists to make assertions about the form of the program, not about its runtime behaviour. That’s why languages like C++ implement const
as an annotation on top of an existing declaration. If JavaScript followed the same philosophy, const
would be an annotation on top of an existing declaration:
It might look something like this:
@const function sumFrom (original, i) {
let sum = 0;
let @const array = original.slice(i);
for (let i in array) {
sum += array[i];
}
return `The sum of the numbers ${original.join(', ')} from ${i} is ${sum}`
}
The secret to understanding const
is to understand that it’s a shorthand for let
with an annotation, as hypothetically shown above. But it’s really just a let
.
what is the value proposition of const?
The value proposition of const
is that we have an annotation that is enforced by static analysis. It’s like a comment that can never mislead the reader, because the compiler forces you to either not rebind a const
or to switch from const
to let
.
How valuable is this comment to the reader of the code?
There’s some argument that restricting variables to being constant “makes a function easier to reason about.” Of course that’s true in the literal English sense, but if you don’t rebind references, a function is just as easy to reason about if you use const
as if you use let
. It’s just that with let
, you have to read the whole function to see which variables are rebound and which aren’t.5
The value of const
is that you don’t have to examine everywhere the variable is used to know that the variable is not rebound. This point cannot be repeated enough, but I’ll settle for repeating it just once: The value of const
is that you don’t have to examine everywhere the variable is used to know that the variable is not rebound.
How valuable is that, exactly?
Variables in JavaScript have a fixed scope: You can see every single rebinding of a variable within the lexical scope of the function, and there’re only two ways to rebind a variable; With a simple assignment, or with a destructuring assignment.
There are no other ways to rebind it. JavaScript does not have indirect variable access like SNOBOL. It does not have pointers to variables like C. It does not have call-by-reference like C++. It does not treat the environment as a mutable dictionary.6
So with a variable, we always know exactly what we have to review. Reasoning about variable rebinding is easy.
const vs. immutability
Consider a related, but mostly orthogonal idea, immutability of data. With immutable data, you have a data structure, like an array, and you never change it. Nothing is added or removed. No elements are changed.
The value of an immutable data structure is that you don’t have to examine everywhere the data structure is accessed to know that the data structure is not mutated. This point also cannot be repeated enough, and again I’ll settle for repeating it just once: The value of an immutable data structure is that you don’t have to examine everywhere the data structure is accessed to know that the data structure is not mutated.
Guaranteeing that an array is immutable means examining everywhere the array is accessed and verifying that none of those accesses mutate the array, much as guaranteeing that a variable is const
means examining everywhere the variable is used and verifying that none of those uses change its binding.
These two things sound the same, but they are not. As we saw above, variables have a fixed scope, we always know exactly what we have to review, and thus reasoning about variables is easy.
Data, on the other hand, is not narrowly scoped. Objects are passed by reference to functions. Objects are returned by reference from functions. Object properties can be dynamically accessed with []
. For this reason, any code within a program could modify data. To truly understand whether an object is mutated, you need to examine the whole program—including libraries and standard classes—and even then there are lots of common cases for which you can make no guarantee.
So with data, we do not always know what we have to review. Reasoning about data is hard.
And that’s exactly why having guarantees about immutability are so valuable in the languages that provide them. But reasoning about variable rebinding is quite a bit easier. And thus, providing a guarantee about variable rebinding may sound like guarantees about data immutability, but it is is considerably less valuable.
so… should we use var, let, and const?
One can see immediately that var
and let
may be theoretically unnecessary, but in practice make the functions we write simpler, and therefore easier to read and write.
Whereas, const
does not make functions simpler than let
, but does provide a kind of annotation that saves us some effort when examining a function. It is not nearly as useful as immutable data, because the problem it solves is easy, not hard.
(discuss on hacker news)
-
It also changes the arity of our functions. That can matter for certain meta-programming implementations. ↩
-
“Immediately Invoked Function Expressions” ↩
-
There are some edge cases with respect to the behaviour of
let
and variables used before they are declared, but the basic principle here is straightforward. ↩ -
From now on, we’ll just translate
let
intovar
and leave removinglet
altogether as an exercise for the reader. ↩ -
You’ll often hear functional programmers talk about immutability making programs easier to reason about. They don’t mean easier in the sense of, “Immutability saves me some effort.” They mean, “It would be impossible to reason about this data without immutability.” They’re using the same words, but in FP, the words “easier to reason about” have a specific technical meaning that does not apply to
const
. We’ll read more about this below. ↩ -
Then again, there’s always
eval
. ↩