DEV Community 👩‍💻👨‍💻

Toby Parent
Toby Parent

Posted on

Getters and Setters in Javascript: What's the POINT?

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. A private scope, contained within a function, and
  2. 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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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("");
Enter fullscreen mode Exit fullscreen mode

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','','','','']
Enter fullscreen mode Exit fullscreen mode

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('');
Enter fullscreen mode Exit fullscreen mode

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; }
  });
};
Enter fullscreen mode Exit fullscreen mode

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 type function. When we typeof game.board.at(0).value, we will be told that it's data of type string. 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"
 *  }
 *}
 ****/
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (13)

Collapse
 
lukeshiru profile image
Luke Shiru • Edited on

I still don't fully get the "why", mainly because I think I could just implement something pretty similar without getters, setters or frizzing, but still avoiding unwanted mutations. Here is the TicTacToe example you provided with "my take" on it to illustrate:

// Curried function to make player creation easier
const createPlayer = icon => name => ({
    name,
    icon,
});

// We make use of `createPlayer` so now `createPlayer1` is waiting for a name
const createPlayer1 = createPlayer("X");
// Same for `createPlayer2`
const createPlayer2 = createPlayer("O");

// We save a default board that we will use as a starting point and a reset.
const defaultBoard = [...Array(9)].fill("");

// Curried util to update values on the state's board
const updateBoard =
    index =>
    value =>
    ({ board }) =>
        [...board.slice(0, index), value, ...board.slice(index + 1)];

// Util to switch the current user in the state
const switchPlayers = ({ player1, player2, currentPlayer }) =>
    currentPlayer === player1 ? player2 : player1;

// This generates the tic-tac-toe state
const ticTacToe = ({ player1Name, player2Name }) => {
    const player1 = createPlayer1(player1Name);

    return {
        player1,
        player2: createPlayer2(player2Name),
        currentPlayer: player1,
        board: defaultBoard,
    };
};

// To start a new game state:
const state = ticTacToe({ player1Name: "Player 1", player2Name: "Player 2" });
/*
{
    player1: { name: "Player 1", icon: "X" },
    player2: { name: "Player 2", icon: "O" },
    currentPlayer: { name: "Player 1", icon: "X" },
    board: ["", "", "", "", "", "", "", "", ""]
}
*/

// To update the index 0 in the board state:
state.board = updateBoard(0)(state.currentPlayer.icon)(state);

// To get the current value of any item in the board:
console.log(state.board[0]);

// To switch the current player:
state.currentPlayer = switchPlayers(state);

// To reset the board:
state.board = defaultBoard;

// To change a player's name:
state.player1 = createPlayer1("New Player 1");
// Or
state.player1.name = "New Player 1";
Enter fullscreen mode Exit fullscreen mode

I feel that having functions with PascalCase names returning objects with functions on them (methods) and getters/setters is almost like doing classes without using the class identifier. If we really want to move away from classes, we should avoid doing the same things we would do with them.

Cheers!

Collapse
 
parenttobias profile image
Toby Parent

There's nothing wrong with the concept of a class, to my mind. The class keyword didn't give us anything we didn't already have, for years, with closures. Further, closures have the added benefit of true private data. Nothing is exposed on the interface without intent, and data is composed and protected. Your examples aren't bad, but you're making all data public, and global.

I'm not really trying to move away from classes or OO so much as I'm showing a viable, established path that embraces the strength of modular code without buying into the weaknesses of prototypal inheritance being made to resemble classical inheritance.

The addition of getters and setters is a convenience more than anything. I like them, I like the flexibility. But this way of writing isn't for everyone, I get that.

All the best, and thank you! Your code is interesting and solid, a great comparison.

Collapse
 
lukeshiru profile image
Luke Shiru • Edited on

Thanks for replying, only one thing about the privacy (when you said "Your examples aren't bad, but you're making all data public, and global"), the idea with the approach in my example is that it could be in a ticTacToe.js file, and only expose with export what I want to make "public". So for example I could just export the ticTacToe function, making it the only "public" thing in my module, and keeping the rest "private" if I wanted to. You mentioned closures, and I 100% agree with you, we just need to also consider modules as an encapsulation method.

Cheers!

Thread Thread
 
parenttobias profile image
Toby Parent

Very well said! I truly appreciate your insight.

Thread Thread
 
intermundos profile image
intermundos

Strongly support modules as encapsulation method. Simple and clean.

Collapse
 
michaelleojacob profile image
Michael Jacob

Great article! @TobyParent ++

I both love and hate that it points out many issues and pitfalls I experienced working on tic tac toe using factories!

The use of Object.freeze was a surprise. Might have to start using that in my own code.

Collapse
 
parenttobias profile image
Toby Parent

It's a powerful principle, simple and elegant. A lot of this derived from reading Eric Elliott's Composing Software, but again - with the exception of the ES6 getters and setters and lambda functions, none of this is new. It's tested, established and it works.

All the best!

Collapse
 
thebox193 profile image
Sir.Nathan (Jonathan Stassen)

Very nice example :)

Collapse
 
parenttobias profile image
Toby Parent

Glad you like it. Honestly, it's mostly me trying to answer a question en masse that I've been handling one on one for a very long time. Hopefully this is clear enough that I can comfortably refer folks here when they ask why the variable isn't updating in their factory... 😁

Collapse
 
thebox193 profile image
Sir.Nathan (Jonathan Stassen)

Oh I hear you there. Most of the items I blog about come out of conversations answering questions. :D

Collapse
 
marksmith90 profile image
Mark Smith

I think so too

🐛 See a bug on this page?

Join our team and help us fix it. We're hiring for a Senior Full Stack Engineer — Head here to learn more and apply.