Way back in my Java days, when interviewing candidates, I would often ask the candidate to explain the meaning of the final
keyword. Many times I would get an answer along the lines of "It's a constant value" or "It can't be changed once assigned".
Fast forward to today, living in JavaScript Land, we now have const
declarations. I ask the same question to JavaScript candidates and often get the same sorts of answers.
On the surface, these answers are more or less correct, but when I would ask follow-up questions about what exactly they meant by "constant" or "can't be changed", I was sometimes surprised by the answers I received.
This appears to be a question that can sometimes trip up beginners. Let's take a deeper look.
The const
keyword
Back in the dark ages, we had var
for declaring variables and that was it! ES2015 gave us two new tools: const
and let
.
We use const
like this:
const name = 'Joe';
I have just declared name
, which is a reference to the string 'Joe'
. If I try to assign a new value to name
, I get an error:
name = 'Liz'; // TypeError: Assignment to constant variable.
Great, so this is a constant value! I can't change the name assigned to name
. This much, I think, is pretty clear.
What about an array?
A string is a primitive value. What if I use const
for, say, an array?
const foods = ['apple', 'banana', 'pear'];
Indeed, I can't set a new array:
foods = ['bacon', 'chicken', 'turkey']; // TypeError: Assignment to constant variable.
I want to take banana
out of my array. I might try to do that with Array.prototype.filter
, but I run into the same problem:
foods = foods.filter(food => food !== 'banana'); // TypeError: Assignment to constant variable.
That won't work because foods
is declared with const
. Of course, this will work fine if I assign the result of foods.filter
to another variable name, but let's pretend that for whatever reason the array referenced by foods
has to have the banana-less array.
Instead of filtering, a value can also be removed from an array with Array.prototype.splice
. But since foods
is a constant that can't be changed, this will fail as well, right?
foods.splice(foods.indexOf('banana'), 1); // No error!
console.log(foods); // ['apple', 'pear']
This works! The array referenced by foods
no longer contains banana
. Why did this work if we used const
?
The reference is the constant!
As it turns out, the constant is the reference itself, not the value it's pointing to. We can do anything we want to the foods
array as long as we don't try to reassign a new value to foods
.
So you see, explaining const
by saying "it's a constant value" or "it can't be changed" is only true from a certain point of view.
Immutable data
What if we really, truly, want to lock down the foods
array so we can't modify it? The reason we can still get away with this is because functions like Array.prototype.splice
mutate the thing. This is just a fancy way of saying we are modifying its internal state.
Some values, like strings, are immutable, so for a string, our simplified explanation of const
is accurate. But for objects and arrays, we still have a mutation escape hatch.
It's actually easy to achieve immutability for our humble array of foods. We can freeze it!
const foods = ['apple', 'banana', 'pear']; // At this point we can still mutate the array
Object.freeze(foods); // Not anymore!
Object.freeze
essentially locks down our array. We can't do anything to it now:
foods.splice(foods.indexOf('banana'), 1); // TypeError: Cannot delete property '2' of [object Array]
foods.push('bacon'); // TypeError: Cannot add property 3, object is not extensible
Now our foods
array is untouchable. Like before, we can still filter
it as long as we assign it to a new name. Interestingly, the new filtered array is no longer frozen:
const noBananas = foods.filter(food => food !== 'banana');
noBananas.push('bacon'); // No error!
console.log(noBananas); // ['apple', 'pear', 'bacon'];
A note on Object.freeze
You may notice that the array threw exceptions when trying to add or remove elements after it was frozen. As we will see soon, this does not happen with other objects (unless in strict mode). When not running in strict mode, the mutation will fail silently.
Freezing objects
Let's take one more example, an array of users, and freeze it.
const users = [
{ username: 'obiwan', email: 'kenobi@gmail.com' },
{ username: 'yoda', email: 'yoda@coruscant.com' }
];
Object.freeze(users);
// This will fail with TypeError: Cannot add property 2, object is not extensible
users.push({ username: 'chewbacca', email: 'chewie@kashyykmail.com' });
To protect his identity, let's change Obi-Wan's username to ben
(the Empire will surely think the last name is a coincidence):
users[0].username = 'ben';
console.log(users[0]); // { username: 'ben', email: 'kenobi@gmail.com' }
No error was thrown, and when we check the user object, it was updated (so it did not fail silently as we discussed above!)
What happened? Isn't the array frozen? Indeed it is, the individual items in the array are not. To achieve this, we need to freeze every item in the array:
const users = [
{ username: 'obiwan', email: 'kenobi@gmail.com' },
{ username: 'yoda', email: 'yoda@coruscant.com' }
];
Object.freeze(users);
users.forEach(user => Object.freeze(user));
// Change Obi-Wan's name.
users[0].username = 'ben';
// No error, let's check the object
console.log(users[0]); // { username: 'obiwan', email: 'kenobi@gmail.com' }
Now that we've frozen each object in the array, we get the desired outcome. Array items can't be added, removed, or modified.
If we ran the same above code in strict mode, it would fail with an error:
TypeError: Cannot assign to read only property 'username' of object '#<Object>'
Recursively freezing objects
At any given level, Object.freeze
will only freeze the top-level properties. For deeply nested objects, you will need to traverse all the way down and freeze at each level.
Fortunately, there is a simple package called deep-freeze
that will do this in a single function call:
import deepFreeze from 'deep-freeze';
const users = [
{ username: 'obiwan', email: 'kenobi@gmail.com' },
{ username: 'yoda', email: 'yoda@coruscant.com' }
];
deepFreeze(users);
This will freeze the array as well as all the objects in it. If those objects had nested object properties, those would be frozen as well. Since we are in an ES module (we're using import
), this automatically puts us in strict mode, so attempting to change Obi-Wan's username will throw an error.
Summary
const
marks a reference as constant; it cannot be reassigned. However, the object pointed to by theconst
can be mutated.- To make an object truly unmodifiable, you will need to freeze it.
Object.freeze
does not recurse into nested properties, but thedeep-freeze
package will.
Now you can crush this question on your next job interview!