JavaScript classes bring the addition of set
and get
keywords to define behaviour when setting and getting property values.
I’d like to share a state management pattern I’ve been using that keeps code private, encapsulated, and easy to manage.
If you’ve not yet explored the world of setters and getters, also known as “accessors” then this post will (hopefully) blow your mind a little.
Previously, I wrote about private properties and methods and how to get it setup with Babel - in this article we’ll take things a little further.
Let’s take a class
example, without private properties or setters:
class Cart {
constructor(items = []) {
this._items = items;
}
get value() {
return this._items;
}
get count() {
return this._items.length;
}
add(item) {
this._items = [...this.value, item];
}
remove(id) {
this._items = this.value.filter((item) => item.id !== id);
}
}
This works just fine, but leads us to a big issue.
Our get value() {}
is the intended way for developers to interact with our code, but it still leaves the this._items
property fully open to access and manipulation:
const cart = new Cart();
const burger = { id: '🍔', name: 'Big Burger', price: 499 };
cart.add(burger);
console.log(cart.value); // ✅ Getter returns this._items [{...}]
console.log(cart._items); // ❌ this._items publicly accessible [{...}]
The naming convention with an underscore is a traditional way to infer that a property is private and shouldn’t be modified or referenced. But this has no effect on whether the data is public or not, so it leaves the data open and vulnerable.
Do we want to expose that data? Probably not.
Inspecting our Cart
class, it gives us:
Cart {
_items: [{...}],
value: [{...}],
count: 1,
prototype: { add: ƒ, remove: ƒ }
}
Note the _items
property, this is where the ‘raw data’ is kept.
The value
getter allows us to transform it on the way out, which we’ll cover shortly.
…So, what can we do?
Let’s introduce a private #items
property to hold our ‘state’:
class Cart {
#items;
constructor(items = []) {
this.#items = items;
}
get value() {
return this.#items;
}
get count() {
return this.#items.length;
}
add(item) {
this.#items = [...this.value, item];
}
remove(id) {
this.#items = this.value.filter((item) => item.id !== id);
}
}
Now we don’t see _items
or #items
, it is completely private and hidden:
Cart {
value: [{...}],
count: 1,
prototype: { add: ƒ, remove: ƒ }
}
That’s the first task out the way, encapsulating and maintaining state with a private property. Safety first!
But what about an even better way to set the value instead of communicating directly to the this.#items
property?
This is an optional choice, but a personal preference.
Let’s use a set value() {}
and then write all values to the this.#items
property:
class Cart {
#items;
constructor(items = []) {
this.value = items;
}
set value(items) {
this.#items = items;
}
get value() {
return this.#items;
}
get count() {
return this.value.length;
}
add(item) {
this.value = [...this.value, item];
}
remove(id) {
this.value = this.value.filter((item) => item.id !== id);
}
}
Notice this.value
is now referenced everywhere inside the class. It feels more complete and clear to me.
In this example, it might not be completely necessary, but I think in a bigger project with more complex functionality using a setter here keeps things contained in one place.
But we’re not done just yet, I want to ensure that our this.value
cannot be tampered with on the outside.
Let’s introduce Object.freeze()
and freeze our data structure when setting the value to prevent mutations:
class Cart {
#items;
constructor(items = []) {
this.value = items;
}
set value(items) {
this.#items = Object.freeze(this.#items);
}
get value() {
return this.#items;
}
// ...
}
Now if we try to use methods like [].push()
or direct mutations, it will not work:
const cart = new Cart();
const burger = { id: '🍔', name: 'Big Burger', price: 499 };
// ✅ Works as intended...
cart.add(burger);
❌ // Uncaught TypeError: Cannot add property 1, object is not extensible
cart.value.push(burger);
❌ // Error: Cannot assign to read only property 0 of object [object Array]
cart.value[0] = burger;
As we set
this.#items
as frozen on the way in, when weget
the value it’s still frozen.
So, that’s my approach to handling private state alongside frozen state. It helps enforce immutability and creates a clean pattern within the class.
We even get the additional benefit inside the constructor
as we use this.value = items
which sets the initial value as frozen too.
Another cool trick, you can check if an object is frozen by using Object.isFrozen()
:
const cart = new Cart([]);
// ✅ true
console.log(Object.isFrozen(cart.value));
This approach is also great because it enforces us to use immutable operations within the class, things like .filter()
are being used and the spread operator to copy or create new values.
Happy setting and getting!