Encapsulation and Private Fields

Information Hiding with Private Fields

In object-oriented languages like Java and C++, we often hide the internal state of an object from code outside the object. We do that by making the properties private and providing public methods to read and modify them. This is called encapsulation.

JavaScript added syntax for private fields recently. We prefix the property name with a # symbol. A private field can only be used inside the class where it is defined. Let’s modify the Todo class to make the id property private:

class Todo {
  #id;

  constructor(id, text, completed = false) {
    this.#id = id;
    this.text = text;
    this.completed = completed;
  }

  toggle() {
    this.completed = !this.completed;
  }
}

Notice that we have prefixed the id property with a # symbol to make it private. We must also declare #id in the class body so that it can be used inside the class. If you run the code, you will see that #id is hidden from outside the class.

As a reminder, this is our code where we create a Todo object and try to access the id property:

const todo = new Todo(1, "Buy milk");
console.log(todo);
todo.toggle();
console.log(todo);

console.log(todo.id);
console.log(todo.text);
console.log(todo.completed);

todo.id = 2;

console.log(todo);

Run the todo.js file using Node.js to see the output. You should see the following output in the console:

Todo { text: 'Buy milk', completed: false }
Todo { text: 'Buy milk', completed: true }
undefined
Buy milk
true
Todo { text: 'Buy milk', completed: true, id: 2 }

Let’s go over this output:

  • The Todo { text: 'Buy milk', completed: false } output shows that the #id property is hidden from outside the class.

  • The Todo { text: 'Buy milk', completed: true } output shows that the toggle method works as expected. The #id property remains hidden.

  • The undefined output says there is no id property on the todo object. Be careful here: we did not ask for the #id property, we asked for the id property.

  • The Buy milk output shows that the text property can be read from outside the class. The text property is not private, so we can read and modify it directly.

  • The true output shows that the completed property can be read from outside the class. The completed property is not private, so we can read and modify it directly.

  • The Todo { text: 'Buy milk', completed: true, id: 2 } output shows there is now an id property on the todo object. The statement todo.id = 2; created it. #id is not the same thing as id. The #id property is still 1 and it is still hidden.

Let’s try something else. Add the following code to the todo.js file:

console.log(todo.#id);

Now run the todo.js file using Node.js to see the output. You should see the following error in the console:

console.log(todo.#id);
                ^

SyntaxError: Private field '#id' must be declared in an enclosing class
    at ESMLoader.moduleStrategy (node:internal/modules/esm/translators:119:18)
    at ESMLoader.moduleProvider (node:internal/modules/esm/loader:468:14)
    at async link (node:internal/modules/esm/module_job:68:21)

Node.js v18.17.0

The error message says Node thought we were declaring a private field on the todo object. That is not what we were doing; we were trying to read the private field #id. The message is misleading, but that is a limitation of how private fields are currently implemented in JavaScript.

If your code editor has a TypeScript linter, you may see a warning that says “Property ‘#id’ is not accessible outside class ‘Todo’ because it has a private identifier.” That is a more accurate description of the problem. The private field #id cannot be used outside the class where it is defined.

We can read the private property indirectly if we define a getter method for it. Add the following method to the Todo class:

   toggle() {
     this.completed = !this.completed;
   }
+
+  getId() {
+    return this.#id;
+  }
 }

Now update the previous console log statement to use the getId method:

console.log(todo.getId());

Run the todo.js file using Node.js to see the output. You should see the following output in the console:

1

JavaScript classes have a special syntax for getter methods. A getter method takes no parameters and is declared with the get keyword. Let’s rewrite our getter method using this syntax:

-  getId() {
-    return this.#id;
-  }
+  get id() {
+    return this.#id;
+  }

Now update the todo.js file to use the new getter method. However, before running the code, also comment out the todo.id = 2; statement:

// todo.id = 2;

console.log(todo);

console.log(todo.id);

Run the todo.js file using Node.js to see the output. You should see the following output in the console:

Todo { text: 'Buy milk', completed: true }
1

The 1 output is the value of the #id property, read through the getter method. Notice that we do not call the getter the way we call a regular method. We write todo.id, as if id were a regular property, and JavaScript runs the getter method to produce the value.

In the todo.js file, if you uncomment the todo.id = 2; statement and run the code, you will see the following error in the console:

todo.id = 2;
        ^

TypeError: Cannot set property id of #<Todo> which has only a getter

Let’s update the Todo class to hide other properties and provide getter and setter methods for them:

class Todo {
  #id;
  #text;
  #completed;

  constructor(id, text, completed = false) {
    this.#id = id;
    this.#text = text;
    this.#completed = completed;
  }

  toggle() {
    this.#completed = !this.#completed;
  }

  get id() {
    return this.#id;
  }

  get text() {
    return this.#text;
  }

  get completed() {
    return this.#completed;
  }

  set text(newText) {
    this.#text = newText;
  }

  set completed(value) {
    this.#completed = value;
  }
}

const todo = new Todo(1, "Buy milk");
console.log(todo);
console.log(todo.id);
console.log(todo.text);
console.log(todo.completed);

// todo.id = 2;
todo.text = "Buy eggs";
todo.completed = true;

console.log(todo);
console.log(todo.id);
console.log(todo.text);
console.log(todo.completed);

Run the todo.js file using Node.js to see the output. You should see the following output in the console:

Todo {}
1
Buy milk
false
Todo {}
1
Buy eggs
true

Let’s make the following observations:

  1. The Todo {} output shows that the #id, #text, and #completed properties are hidden from code outside the class.

  2. The 1 output shows the value of the #id property accessed through the getter method.

  3. The commented out code todo.id = 2; would throw an error because the id property has only a getter method and no setter method.

  4. The Buy milk output shows the value of the #text property accessed through the getter method. The statement todo.text = "Buy eggs"; updates the value of the #text property using the setter method. Notice we defined a setter method for the text property. The syntax for a setter method is like the syntax for a getter method, but with the set keyword. Like a getter method, we do not call a setter method directly. It runs when we assign to the property.

  5. We also defined a setter method for the completed property. The statement todo.completed = true; updates the value of the #completed property using the setter method. The printed false and true values show the completion status before and after this assignment. Note that we still have the toggle method available for when we want to flip the current state rather than set a specific value.

Class syntax has been part of JavaScript since ES6 (2015), but private fields (the # prefix) were added later, in ES2022. Modern browsers and Node.js support private fields, but you will run into older codebases that do not use them. Some developers also prefer other ways of doing encapsulation, such as closures or naming conventions like prefixing a private property with _.

Checkpoint: Commit your progress.

git add .
git commit -m "todos-16: Add private fields and getters/setters"
git push