Write JavaScript like a pro. Javascript Icon

Follow the ultimate JavaScript roadmap.

Setters and Getters with Object.​freeze() for Private State

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 we get 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!

Learn JavaScript the right way.

The most complete guide to learning JavaScript ever built.
Trusted by 82,951 students.

Todd Motto

with Todd Motto

Google Developer Expert icon Google Developer Expert

Related blogs 🚀

Free eBooks:

Angular Directives In-Depth eBook Cover

JavaScript Array Methods eBook Cover

NestJS Build a RESTful CRUD API eBook Cover