Skip to content
aviral gupta

// I2.4 · ~34 min · Intermediate

Private fields, getters and static members

After this lesson you protect a class’s data with #private fields, offer it through getters and setters that check new values, and add static methods such as a factory.

Lesson 4 of 5 in I2 Objects, prototypes and classes

You will be able to

  • Hide data in #private fields and methods, and check for them with #x in obj
  • Offer computed and checked values with get and set accessors
  • Write static methods and fields, including a static factory method
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I2.3. What does this print?

    class Base {
      hello() {
        return "base";
      }
    }
    class Child extends Base {
      hello() {
        return `child + ${super.hello()}`;
      }
    }
    console.log(new Child().hello());
  2. Predict · Activity 2 of 7

    Predict before you read on. Someone sets c.count from outside. What does this print?

    class Counter {
      #count = 0;
      increment() {
        this.#count += 1;
        return this.#count;
      }
    }
    const c = new Counter();
    c.increment();
    c.count = 100;
    console.log(c.increment(), Object.keys(c).join(","));
  3. Practice · Activity 3 of 7

    Fill in the keyword so that cart.total is read like a property, without parentheses.

    class Cart {
      #prices = [3, 4];
      ____ total() {
        return this.#prices.reduce((sum, price) => sum + price, 0);
      }
    }
    console.log(new Cart().total);
    #prices = [3, 4];total() {
  4. Practice · Activity 4 of 7

    Match each part of a class to what it gives you.

  5. Practice · Activity 5 of 7

    A static factory. What does this print?

    class Temp {
      constructor(celsius) {
        this.celsius = celsius;
      }
      static fromFahrenheit(f) {
        return new Temp(((f - 32) * 5) / 9);
      }
    }
    const t = Temp.fromFahrenheit(212);
    console.log(t.celsius, typeof t.fromFahrenheit, typeof Temp.fromFahrenheit);
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Two ways to ask about #id. What does this print?

    class Ticket {
      #id;
      constructor(id) {
        this.#id = id;
      }
      static isTicket(value) {
        return #id in value;
      }
    }
    const fake = {id: 1};
    console.log(Ticket.isTicket(new Ticket(1)), Ticket.isTicket(fake), "#id" in new Ticket(2));
  7. Apply · Activity 7 of 7

    Mini-task: write a class Rating that keeps its stars in a private #stars field. A setter stars accepts only whole numbers from 1 to 5 and throws a RangeError otherwise; the constructor uses it too. A getter stars returns the value, a getter label returns a text such as "4 of 5". Add a static average(ratings). Show that a wrong value is refused and the old one kept.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

An account that guards its balance

Account keeps the owner and the balance in private fields. A setter cleans and checks the owner, a getter makes the balance read-only, and the private #check(amount) refuses amounts that are not positive. Static members belong to the class: a currency, a counter, a factory that builds an account from JSON, and isAccount, which uses #balance in value. The loop tries four forbidden things.

main.js

class Account {
  static currency = "EUR";
  static count = 0;

  #owner;
  #balance = 0;

  constructor(owner) {
    this.owner = owner; // runs the setter below
    Account.count += 1;
  }

  static fromJSON(text) {
    const data = JSON.parse(text);
    const account = new Account(data.owner);
    account.deposit(data.balance);
    return account;
  }

  static isAccount(value) {
    return #balance in value;
  }

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

  set owner(name) {
    if (typeof name !== "string" || name.trim() === "") {
      throw new TypeError("owner must be a non-empty string");
    }
    this.#owner = name.trim();
  }

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

  deposit(amount) {
    this.#check(amount);
    this.#balance += amount;
  }

  withdraw(amount) {
    this.#check(amount);
    if (amount > this.#balance) throw new RangeError("not enough money");
    this.#balance -= amount;
  }

  #check(amount) {
    if (!(amount > 0)) throw new RangeError(`invalid amount: ${amount}`);
  }
}

const ada = new Account("  Ada ");
ada.deposit(100);
ada.withdraw(30);
console.log(ada.owner, ada.balance, Account.currency);
console.log(ada, Object.keys(ada));

for (const attempt of [() => (ada.balance = 1000000), () => ada.withdraw(500), () => ada.deposit(-5), () => (ada.owner = " ")]) {
  try {
    attempt();
  } catch (error) {
    console.log(`${error.name}: ${error.message}`);
  }
}

const max = Account.fromJSON('{"owner": "Max", "balance": 50}');
console.log(max.owner, max.balance, Account.count);
console.log(Account.isAccount(max), Account.isAccount({balance: 50}));
console.log(typeof ada.fromJSON);

Run it with

node main.js

Output

Ada 70 EUR
Account {} []
TypeError: Cannot set property balance of #<Account> which has only a getter
RangeError: not enough money
RangeError: invalid amount: -5
TypeError: owner must be a non-empty string
Max 50 2
true false
undefined
  • The setter trimmed " Ada " to "Ada"; the constructor’s this.owner = owner already went through it.
  • Node.js prints the account as Account {}, and Object.keys finds nothing: all its data is private.
  • balance has only a getter, so assigning to it threw a TypeError and the balance stayed 70.
  • Account.count counts every account, including the one fromJSON built.
  • A plain object with a balance property is not an Account: it has no #balance.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Exercises

Exercise 1 of 2

A temperature that refuses the impossible

Temperature should keep its value in a private #celsius field, behind a getter and a setter called celsius. The setter throws a RangeError with the message below absolute zero for values under -273.15, also when the constructor is given one. The fahrenheit getter already works. The starter stores celsius as a public property and checks nothing.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: -300 is accepted, and Object.keys finds celsius. Where should the value live instead?

  2. Hint 2

    Declare #celsius; in the class body. get celsius() returns it; set celsius(value) checks value and then stores it.

  3. Hint 3

    In the setter: if (value < -273.15) throw new RangeError("below absolute zero"); this.#celsius = value;

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export class Temperature {
  #celsius;

  constructor(celsius) {
    this.celsius = celsius;
  }

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

  set celsius(value) {
    if (value < -273.15) throw new RangeError("below absolute zero");
    this.#celsius = value;
  }

  get fahrenheit() {
    return (this.celsius * 9) / 5 + 32;
  }
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export class Temperature {
  constructor(celsius) {
    this.celsius = celsius;
  }

  get fahrenheit() {
    return (this.celsius * 9) / 5 + 32;
  }
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {Temperature} from './main.js';

test('celsius and fahrenheit can be read', () => {
  const t = new Temperature(100);
  assert.deepEqual([t.celsius, t.fahrenheit], [100, 212], `celsius and fahrenheit are ${t.celsius} and ${t.fahrenheit}`);
});

test('Setting a value below -273.15 throws and keeps the old value', () => {
  const t = new Temperature(20);
  assert.throws(() => { t.celsius = -300; }, {name: 'RangeError', message: 'below absolute zero'}, 'setting -300 should throw a RangeError');
  assert.equal(t.celsius, 20, `after the refused value celsius is ${t.celsius}`);
});

test('The constructor refuses -300 too', () => {
  assert.throws(() => new Temperature(-300), {name: 'RangeError'}, 'new Temperature(-300) should throw a RangeError');
});

test('The value is not a public own property', () => {
  assert.deepEqual(Object.keys(new Temperature(5)), [], 'Object.keys should find nothing: keep the value in #celsius');
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 2

A static factory and a static field

Color.fromHex("#ff8000") should build a Color from a hex text: the pairs ff, 80 and 00 are red, green and blue in base 16. The starter has fromHex as an ordinary method, so you would need a colour to make a colour. Make it static. Then add a static field WHITE, a Color with 255 for all three parts.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: Color.fromHex is undefined. Which keyword puts a method on the class itself?

  2. Hint 2

    Write static before fromHex(hex). A static field looks like static NAME = value;

  3. Hint 3

    static WHITE = new Color(255, 255, 255);

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export class Color {
  constructor(r, g, b) {
    this.r = r;
    this.g = g;
    this.b = b;
  }

  static WHITE = new Color(255, 255, 255);

  static fromHex(hex) {
    const part = (start) => parseInt(hex.slice(start, start + 2), 16);
    return new Color(part(1), part(3), part(5));
  }

  toString() {
    return `rgb(${this.r}, ${this.g}, ${this.b})`;
  }
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export class Color {
  constructor(r, g, b) {
    this.r = r;
    this.g = g;
    this.b = b;
  }

  fromHex(hex) {
    const part = (start) => parseInt(hex.slice(start, start + 2), 16);
    return new Color(part(1), part(3), part(5));
  }

  toString() {
    return `rgb(${this.r}, ${this.g}, ${this.b})`;
  }
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {Color} from './main.js';

test('Color.fromHex builds a Color', () => {
  assert.equal(typeof Color.fromHex, 'function', 'Color.fromHex should be a static method');
  const c = Color.fromHex('#ff8000');
  assert.ok(c instanceof Color, 'fromHex should return a Color');
  assert.equal(String(c), 'rgb(255, 128, 0)', `fromHex('#ff8000') gave ${c}`);
});

test('fromHex is not a method of a colour', () => {
  assert.equal(typeof new Color(0, 0, 0).fromHex, 'undefined', 'fromHex should be on the class, not on each colour');
});

test('Color.WHITE is white', () => {
  assert.equal(String(Color.WHITE), 'rgb(255, 255, 255)', `Color.WHITE is ${Color.WHITE}`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Reading a private field from outside the class

class Account {
  #balance = 50;
}
const account = new Account();
console.log(account.#balance);

What Node.js prints

SyntaxError: Private field '#balance' must be declared in an enclosing class

Why, and the fix

A #name exists only inside the class body that declares it. Outside, the code is not even valid, so the whole file fails to start, not just this line. Give the class a way to share the value, such as get balance() { return this.#balance; }, and read account.balance.

A getter that reads itself

class Cart {
  constructor(prices) {
    this.prices = prices;
  }
  get total() {
    return this.total ?? this.prices.reduce((sum, p) => sum + p, 0);
  }
}
console.log(new Cart([3, 4]).total);

What Node.js prints

RangeError: Maximum call stack size exceeded

Why, and the fix

Inside get total(), this.total reads the property total again, which calls the same getter, again and again until the call stack is full. A getter must read something else: compute the value, or keep it in a field with another name, usually a private one such as #total.

Calling a static method on an instance

class Account {
  constructor(owner) {
    this.owner = owner;
  }
  static fromJSON(text) {
    return new Account(JSON.parse(text).owner);
  }
}
const ada = new Account("Ada");
const max = ada.fromJSON('{"owner": "Max"}');
console.log(max.owner);

What Node.js prints

TypeError: ada.fromJSON is not a function

Why, and the fix

A static method lives on the class, not on Account.prototype, so instances do not inherit it and ada.fromJSON is undefined. Call it on the class: Account.fromJSON('{"owner": "Max"}').

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Private fields and methods

A name that starts with #, such as #balance, is private: only code inside the class body can read or change it. Declare it in the class body (#balance = 0;) before you use it. Outside the class, obj.#balance is not even valid code: the whole script fails with a SyntaxError before it runs. Private fields do not show up in Object.keys or JSON.stringify, and Node.js prints the object without them. Methods can be private too, #check(amount), for helpers that are not part of the public interface. #balance in obj tells whether obj was made by this class.

Getters and setters

get balance() { … } runs when someone reads obj.balance, without parentheses, so a value can be computed or read from a private field. set owner(name) { … } runs on obj.owner = value; it can check the value and throw before storing it. A getter without a setter makes the property read-only: in a module or class, assigning to it throws a TypeError. Store the value under another name, usually a #private field. A getter that reads its own name, return this.total inside get total(), calls itself until a RangeError.

Static methods and fields

static puts a method or field on the class itself, not on its instances: Account.count, Account.fromJSON(text). Use it for things that belong to the whole kind of object, such as a counter, a setting, or a helper. Instances cannot reach static members: ada.fromJSON is undefined. A static factory is a static method that builds and returns a new instance, such as from JSON text or from a hex colour, so the constructor can stay simple. Inside a static method, this is the class it was called on.

Sources

Last reviewed October 5, 2026