Getters and Setters in Javascript: What's the POINT?
Toby Parent
Posted on December 10, 2021
The Why
Mentoring on FreeCodeCamp and The Odin Project, you'll often see the Thermometer
project as an introduction to getters and setters in javascript. You know the one:
class Thermostat{
constructor(fahrenheit){
this.fahrenheit = fahrenheit;
}
get temperature(){
return 5/9 * (this.fahrenheit-32)
}
set temperature(tempInC){
this.fahrenheit = tempInC * 9/5+32
}
}
const thermos = new Thermostat(76); // Setting in Fahrenheit scale
console.log(thermos.temperature); // 24.44 in Celsius
thermos.temperature = 26;
console.log(thermos.temperature); // 26 in Celsius
And that's lovely. Does exactly what we want, defines a pretty interface for the temperature
property on the Thermostat
object. But it's terrible, in that not only is that temperature
an exposed property, so is the fahrenheit
. Given that the properties are public anyway, what's the point of getters and setters?
More Why
We could sidestep the issue by using ES6's private properties, simply doing this:
class Thermostat{
constructor(fahrenheit){
this.#fahrenheit = fahrenheit;
}
get temperature(){
return 5/9 * (this.#fahrenheit-32)
}
set temperature(tempInC){
this.#fahrenheit = tempInC * 9/5+32
}
}
Now, from the outside, Thermostat.fahrenheit
no longer exists. Its a private property. Thank you, ES6!
And yet, I am not a fan. Private properties or methods (and private static properties or methods) just feel like a hacky duct-tape solution to a problem that doesn't actually exist. Why? Because we already had private properties.
The What
Private "sandboxes" for our data are nothing new. Javascript has always kept a private scope for functions. And if you've been at this a bit, you'll see reference to closures. A closure is composed of two separate parts:
- A private scope, contained within a function, and
- Some means of accessing variables within that scope.
You see, functions execute, create their private scope, set up their variables, do their instructions, then quietly get swept out with the trash. As soon as nothing is observing the variables in a function, its data becomes available for garbage collection, freeing that memory for other code.
But we don't have to allow that. By returning something that continues to observe that function's scope, even after the function is done executing, we can continue to maintain and update the values contained within it.
Let's take a look at that Thermometer
example again, this time with a closure:
const Thermostat = (fahrenheit) => {
// here, we have the variable fahrenheit.
// completely hidden from the outside world.
// we'll define those same getters and setters
// but note we access the variable, not a property
return {
get temperature(){
return 5/9 * (fahrenheit-32)
},
set temperature(tempInC){
fahrenheit = tempInC * 9/5+32
}
}
}
// note this: we aren't using Thermometer as an
// object constructor, simply as an executed function.
const thermos = Thermostat(76);
// and from here on, it works exactly the same!
console.log(thermos.temperature); // 24.44 in Celsius
thermos.temperature = 26;
console.log(thermos.temperature); // 26 in Celsius
So we have private data within that closure, in the variables. And we define an accessor object, and return that. That defines the interface we use to talk to that private data.
The Gotcha
Again, when fielding questions on The Odin Project's Discord server, I'll field this same gotcha multiple times a week. It's a biggie, and it doesn't always make sense. Consider this:
const TicTacToe = ()=>{
let board = new Array(9).fill("");
let player1 = {name: 'Margaret', icon: 'X'};
let player2 = {name: 'Bert', icon: 'O'};
let currentPlayer = player1;
const switchPlayers = () => {
if(currentPlayer===player1){
currentPlayer=player2;
} else {
currentPlayer=player1;
}
}
// and our return interface:
return {
switchPlayers,
currentPlayer,
board
}
};
// let's make a board!
const game = TicTacToe();
// And let's play a little!
game.board[4] = game.currentPlayer.icon;
console.log(game.board);
// [null, null, null, null, 'X', null, null, null, null]
// switch to player2...
game.switchPlayers();
game.board[0] = game.currentPlayer.icon;
console.log(game.board)
// ['X', null, null, null, 'X', null, null, null, null]
Did you note that last return? game.board[0]
, which we set to game.currentPlayer.icon
, is the wrong player! Did our game.switchPlayers()
not work?
Actually, it did. If you were to open the browser's dev tools and inspect the variables inside that closure, you'd see that currentPlayer===player2
. But game.currentPlayer
is still referring to player1
.
This is because, when we created the object that we returned inside our closure, we referred to the variable as a static reference to the value at the moment we created it. We took a snapshot of that primitive. Then we update the variable, pointing it to a new memory location, but the object property is completely disconnected from the variable!
"Yeah, but what about the game.board
? We're updating that on the object and it's updating the variable, right?"
You're absolutely right. We do game.board[4]='X'
, and that is updating both the variable, and the returned object property. The reason? We're mutating that array. We're mucking about with its insides, but we are leaving the variable and property reference alone. Suppose we wanted to reset the board, we could do this:
game.board = new Array(9).fill("");
Clears the game.board
, all set for another! And what we've just done is the same problem in reverse. We've changed the thing that game.board
refers to, pointed it at a new location in memory, but the variable still refers to the original.
Well, that isn't our intent at all!
Once More With the Why
Why did that happen? Because we sort of abandoned one of the principle tenets of Object Oriented development. There are three:
- Encapsulation (how can we hide our stuff?)
- Communication (how can we set and get our hidden stuff?)
- Late Instantiation *(can we dynamically make new stuff as we execute?)
We have the third one down pat, but we've sort of trampled on the first two. By exposing our data directly on the returned object, it is no longer encapsulated, and our communcation is questionable.
The How
The solution? We create an interface and return that! We want to be able to switchPlayers
, and we want to be able to get the currentPlayer
. We also want to see the state of the board
at any point, but we should never set that directly. We might also want to be able to reset the board at some point.
So let's think about an interface:
- For the player, we likely want to be able to get their name and icon. That's pretty much it.
- For the board, it'd be nice to be able to get or set a value at a particular cell, reset the board, and get the value of the board as a whole.
- For the game, how about we expose that board (the interface, not the data), create that switchPlayers function, and make currentPlayer an interface method, rather than directly exposing the data?
That's pretty much it. We could add the checkForWin
functionality to either the board or the game, but that isn't really relevant to this as an exercise in data encapsulation.
With that, let's code!
const Player = (name, icon) => {
return {
get name(){ return name; },
get icon(){ return icon; },
}
}
const Board = () => {
let board = new Array(9).fill("");
// .at will be an interface method,
// letting us get and set a board member
const at = (index) => ({
get value(){ return board[index] },
set value(val){ board[index] = val; }
})
const reset = () => board.fill("");
return {
at,
reset,
get value(){ return [...board];}
}
}
const TicTacToe = (player1Name, player2Name)=>{
let board = Board();
let player1 = Player(player1Name, 'X');
let player2 = Player(player2Name, 'O');
let currentPlayer = player1;
const switchPlayers = () => {
if(currentPlayer===player1){
currentPlayer=player2;
} else {
currentPlayer=player1;
}
}
// and our return interface:
return {
switchPlayers,
board,
get currentPlayer(){ return currentPlayer; }
}
};
// now we can:
const game = TicTacToe('Margaret','Bert');
game.board.at(4).value=game.currentPlayer.icon;
console.log(game.board.value);
// ['','','','','X','','','','']
// all good so far, but now:
game.switchPlayers();
game.board.at(0).value=game.currentPlayer.icon;
console.log(game.board.value);
// ['O','','','','X','','','','']
Nice! Now, because we are not working with the data directly, we can manipulate the data by a clean, consistent interface. If we work with the board
interface methods, we consistently refer to the internal state data, rather than the exposed reference point.
Now, there is a serious gotcha to consider here. What might happen if we did this?
game.board = new Array(9).fill('');
With that, we've again broken the connection between the internal board
variable and the exposed board
interface. We haven't solved ANYTHING!
Well, we have, but we're missing a step. We need to protect our data. So a small change to all of our factory methods:
const Player = (name, icon) => {
return Object.freeze({
get name(){ return name; },
get icon(){ return icon; },
});
};
const Board = () => {
// all the same code here...
return Object.freeze({
at,
reset,
get value(){ return [...board];}
});
};
const TicTacToe = (player1Name, player2Name)=>{
// all this stays the same...
return Object.freeze({
switchPlayers,
board,
get currentPlayer(){ return currentPlayer; }
});
};
By applying Object.freeze()
to each of those factories' returned objects, we prevent them from being overwritten or having methods added unexpectedly. An added benefit, our getter methods (like the board.value
) are truly read-only.
The Recap
So getters and setters in the context of a factory are very sensible to me, for a number of reasons. First, they're object methods that are interacting with truly private variables, making them privileged. Second, by defining just a getter, we can define read-only properties quickly and easily, again going back to a solid interface.
Two more less obvious points I really like about getters and setters:
When we
Object.freeze()
our objects, any primitive data on that object is immutable. This is really useful, but our exposed setters? Yeah, they still work. They're a method, rather than a primitive.BUT, when we
typeof game.board.at
, we will be told that it's data of typefunction
. When wetypeof game.board.at(0).value
, we will be told that it's data of typestring
. Even though we know it's a function!
This second point is wildly useful, and often unappreciated. Why? Because when we JSON.stringify(game)
, all of its function
elements will be removed. JSON.stringify()
crawls an object, discards all functions, and then turns nested objects or arrays into strings. So, if we do this:
json.stringify(game);
/****
* we get this:
*
*{
* "board": {
* "value": [
* "O",
* "",
* "",
* "",
* "X",
* "",
* "",
* "",
* ""
* ]
* },
* "currentPlayer": {
* "name": "Bert",
* "icon": "O"
* }
*}
****/
This seems silly, maybe - but what it means is, with well-defined getters, we can have a saveable state for our objects. From this, we could re-create most of the game
later. We might want to add a players
getter, giving us an array of the players themselves, but the point remains... getters and setters are more useful than we think at first glance!
Posted on December 10, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 22, 2024