JavaScript private fields, getters, and static members

Hide state with #private fields, expose it through a getter, and keep a counter on the class itself.

JavaScript
class Account {
  #balance = 0;
  static count = 0;

  constructor(owner) {
    this.owner = owner;
    Account.count++;
  }

  deposit(amount) {
    if (amount <= 0) {
      throw new RangeError("amount must be positive");
    }
    this.#balance += amount;
    return this;
  }

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

const account = new Account("Ada");
account.deposit(50).deposit(25);
console.log(account.balance, Account.count);

try {
  account.deposit(-1);
} catch (error) {
  console.log(error.name + ": " + error.message);
}

console.log(Object.keys(account).join(","));
console.log(JSON.stringify(account));
Output
75 1
RangeError: amount must be positive
owner
{"owner":"Ada"}

Explanation

A field whose name starts with # is private: only code inside the class body can read or write it. Outside code that tries to access it gets a syntax error, and the field does not appear in Object.keys or JSON.stringify. This is real encapsulation, unlike the old convention of a leading underscore.

A get accessor looks like a property but runs a method, so a value can be read without being writable. A static member belongs to the class itself rather than to instances, which is useful for counters and factory functions. Methods that return this can be chained, as in deposit(50).deposit(25).

Explore the dry run
Guided walkthroughReady
1class Account {
2 #balance = 0;
3 static count = 0;
4
5 constructor(owner) {
6 this.owner = owner;
7 Account.count++;
8 }
9
10 deposit(amount) {
11 if (amount <= 0) {
12 throw new RangeError("amount must be positive");
13 }
14 this.#balance += amount;
15 return this;
16 }
17
18 get balance() {
19 return this.#balance;
20 }
21}
22
23const account = new Account("Ada");
24account.deposit(50).deposit(25);
25console.log(account.balance, Account.count);
26
27try {
28 account.deposit(-1);
29} catch (error) {
30 console.log(error.name + ": " + error.message);
31}
32
33console.log(Object.keys(account).join(","));
34console.log(JSON.stringify(account));

Follow the example

Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.

View the complete dry-run table
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput so far
A private field: invisible outside the class.—
—
One counter shared by the class, not stored on each account.—
—
Returning this lets calls be chained.—
—
Two chained deposits make the balance 75.—
—
The getter reads the private field; the static counter counts one account.—
75 1
The class rejects invalid input to protect its own state.—
75 1
RangeError: amount must be positive
Only the public field owner is listed. Private fields are not properties.—
75 1
RangeError: amount must be positive
owner
The balance is not serialized either.—
75 1
RangeError: amount must be positive
owner
{"owner":"Ada"}

Cost and common mistakes

All operations are O(1).

  • Private fields cannot be accessed from subclasses. Use a protected-style getter or a normal method.
  • A getter without a setter ignores assignments in sloppy mode and throws a TypeError in strict mode.
  • JSON.stringify skips private fields, so add a toJSON method if they need to be saved.

Try it yourself

How would you let the balance be read but never set directly?

Reveal answer and reasoning

Provide only a getter and no setter, and change the field through methods such as deposit that enforce the rules.

Further reading