Mert Özen Further With Every Line
Frontend JavaScript Intermediate — #07

JavaScript Classes: Constructor, Getters/Setters, Static and Private Fields

Mert Özen Nov 3, 2026 9 min 6 views
JavaScript Classes: Constructor, Getters/Setters, Static and Private Fields

We build class syntax with a cookie-cutter analogy and work through constructors, getters/setters, static members, # private fields, extends and super, then see that a class is really prototypes underneath.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

Here is a small counter class. What does the last line print to the console?

class Counter {
  #count = 0;

  increment() {
    this.#count++;
    return this;
  }

  get value() {
    return this.#count;
  }
}

const c = new Counter();
c.increment().increment();
console.log(c.value, c.count);

Make a guess before you keep reading. I won't give you a hint, but I will say this: almost every topic in this post is hiding in those few lines. We'll come back to the answer after we've covered private fields.

Picture a cookie cutter. You can't eat the cutter itself, but you can stamp out hundreds of identically shaped cookies with it. Each cookie can get its own decoration, yet they all share the same shape because they came from the same cutter. A class is exactly that cutter. Every cookie it produces is an instance, an object created from that class.

So far we've written objects one by one with object literals. That's fine for a single user, but when you need a hundred users with the same structure and the same methods, copying the same code over and over invites bugs and wastes memory. Class syntax arrived with ES2015 and gives us a tidy, readable way to do this.

constructor, Methods and Fields

In its simplest form, a class looks like this. Paste it into your browser console and try it:

class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  greet() {
    return `Hi, I'm ${this.name}`;
  }
}

const alice = new User("Alice", "alice@example.com");
const bob = new User("Bob", "bob@example.com");

console.log(alice.greet()); // "Hi, I'm Alice"
console.log(bob.name);      // "Bob"

When you write new User(...), the engine does a few things behind the scenes: it creates an empty object, hands it to the constructor as this, runs the assignments inside the constructor and finally returns that object to you. The constructor is a special method; it runs once per new call and sets up the initial state of the instance.

Methods like greet, on the other hand, are not copied onto each instance. They live in one place and every instance shares them. Type alice.greet === bob.greet and you'll see true. There is only one cutter; the cookies just use it.

Since ES2022 we can declare fields directly in the class body without touching the constructor. For properties with default values, that's much cleaner:

class Task {
  done = false;
  createdAt = new Date();

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

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

const t = new Task("Finish the blog post");
t.toggle();
console.log(t.done); // true

Keep the difference between methods and fields in mind: fields are created on each instance, methods are shared. That detail will matter in the pitfalls section.

Getters and Setters: Methods That Look Like Properties

Sometimes you'd rather compute a value on demand than store it. Or you want to step in and check a value as it's being assigned. That's what get and set are for:

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

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

  set fahrenheit(value) {
    this.celsius = (value - 32) * 5 / 9;
  }
}

const temp = new Temperature(25);
console.log(temp.fahrenheit); // 77

temp.fahrenheit = 212;
console.log(temp.celsius);    // 100

Notice there are no parentheses in temp.fahrenheit. From the outside it looks like a plain property; inside, a calculation runs on every read. Think of a thermostat: the number on the display isn't written down anywhere, it's read from the sensor right then.

The most common use of a setter is validation. Combine it with the error handling we saw earlier and you can stop an invalid value at the door:

class Product {
  constructor(name, price) {
    this.name = name;
    this.price = price; // the setter runs here too
  }

  get price() {
    return this._price;
  }

  set price(value) {
    if (typeof value !== "number" || value < 0) {
      throw new RangeError("Price cannot be negative");
    }
    this._price = value;
  }
}

const pen = new Product("Pen", 15);
pen.price = -5; // RangeError: Price cannot be negative

We kept the value in a separate property called _price. The underscore used to be a convention for "don't touch this", but it doesn't actually protect anything; anyone who writes pen._price = -5 skips the check. We'll see real protection with # in a moment.

static: Belonging to the Class, Not the Instance

Some methods aren't about one particular user but about the idea of a user. Creating a user from JSON, or counting how many users have been created, for example. These go on the class itself with static:

class User {
  static count = 0;

  constructor(name) {
    this.name = name;
    User.count++;
  }

  static fromJSON(json) {
    const data = JSON.parse(json);
    return new User(data.name);
  }
}

const u = User.fromJSON('{"name":"Zoe"}');
console.log(u.name);     // "Zoe"
console.log(User.count); // 1
console.log(u.fromJSON); // undefined

If the console complains that a class "has already been declared" when you define one with the same name again, reload the page and paste it then. You can't reach static members through an instance; you call them with the class name. The Math.max(), Array.from() and Number.isNaN() you use all the time are static methods too. Nobody writes new Math().

For static setup that takes more than a line or two, ES2022 introduced the static block. It runs once, at the moment the class is defined, and this inside it is the class itself:

class Config {
  static defaults;

  static {
    try {
      this.defaults = JSON.parse('{"theme":"dark","lang":"en"}');
    } catch {
      this.defaults = { theme: "light", lang: "en" };
    }
  }
}

console.log(Config.defaults.theme); // "dark"

Private Fields and Methods: Real Privacy with #

Unlike the underscore convention, the # prefix that came with ES2022 builds an actual wall. A field or method whose name starts with # can only be accessed from inside that class's body:

class BankAccount {
  #balance = 0;

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

  withdraw(amount) {
    this.#check(amount);
    if (amount > this.#balance) {
      throw new Error("Insufficient funds");
    }
    this.#balance -= amount;
  }

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

  #check(amount) {
    if (amount <= 0) throw new RangeError("Amount must be positive");
  }
}

const account = new BankAccount();
account.deposit(500);
account.withdraw(120);
console.log(account.balance);  // 380
console.log(account.#balance); // SyntaxError

Run this in a script inside an HTML file and the last line produces a SyntaxError before any code runs. In other words, the engine catches the attempt to touch #balance from outside the class while it's reading the code, not while running it. One note: Chrome's console may let you read private fields to make debugging easier. That is a DevTools privilege only; in real code this line never runs.

Think of a bank vault. You can ask the teller for your balance, deposit and withdraw money, but you can't walk into the vault and change the number by hand. The teller, meaning deposit and withdraw, checks the rules on every transaction.

Now back to the opening question. c.value is a getter that reads the private #count, which was incremented twice, so it returns 2. c.count is something else entirely: the object has no regular property called count, so you get undefined. #count and count are two separate names. The answer is 2 undefined. And because increment() returns this, the chained call works.

You can test whether an object has a particular private field with #balance in obj. That also arrived in ES2022 and can only be written inside the class.

extends and super: Deriving One Class from Another

To extend a class and give it new abilities, we use extends. The subclass gets all the parent's methods; if you want, you can replace some of them with your own version:

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    return `${this.name} makes a sound`;
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }

  speak() {
    return `${super.speak()}: Woof!`;
  }
}

const rex = new Dog("Rex", "Beagle");
console.log(rex.speak());
// "Rex makes a sound: Woof!"

console.log(rex instanceof Dog);    // true
console.log(rex instanceof Animal); // true

super is used in two different ways. Inside the constructor, super(name) calls the parent's constructor. Inside a method, super.speak() reaches the parent's method of the same name, so you can build on top of the parent's behavior instead of throwing it away.

instanceof tells you whether an object comes from a particular class or one of its descendants. Rex is both a dog and an animal.

Private fields behave differently under inheritance. A #balance declared in the parent also exists on a subclass instance, but the subclass's code cannot reach it with this.#balance. Each class owns its private fields. If the subclass needs that value, the parent should offer a getter or a method.

Behind the Curtain: A Class Is Really a Function

Try this in the console:

class User {
  greet() {
    return "Hello";
  }
}

console.log(typeof User);                    // "function"
console.log(typeof User.prototype.greet);    // "function"
console.log(Object.getPrototypeOf(new User()) === User.prototype); // true

Classes didn't bring a new object model to JavaScript. They're a more readable syntax layered over the prototype mechanism that was always there. That's also why methods are shared between instances: they all sit on User.prototype, and instances find them through the prototype chain. We'll dig into how that chain works in the Advanced series; for now it's enough to say that a class is the polite face of prototypes.

Common Pitfalls

The first is forgetting new. A class may be a function, but you can't call it like an ordinary one:

class User {}
User(); // TypeError: Class constructor User cannot be invoked without 'new'

The second is using this before super() in a subclass. In a subclass constructor, this is only ready after the parent has finished its own setup. Try it with the Animal class from above:

class Cat extends Animal {
  constructor(name) {
    this.lives = 9; // ReferenceError
    super(name);
  }
}

new Cat("Tabby");

The third is the lost method we saw in the post on this. When you hand a method to something else as a callback, its link to the object breaks:

class Timer {
  seconds = 0;

  tick() {
    this.seconds++;
    console.log(this.seconds);
  }
}

const timer = new Timer();
setTimeout(timer.tick, 1000); // NaN, because this is no longer timer

The fix is either to wrap it, setTimeout(() => timer.tick(), 1000), or to define the method as an arrow function field: tick = () => { this.seconds++; }. An arrow function field locks this to the instance; the price is that the function is no longer shared and gets created separately for every instance.

The fourth is using a class before its declaration. Class declarations sit in the TDZ just like let and const; you can't use them early the way you can with a function declaration. And finally: a class body always runs in strict mode, so the rules apply even if you never wrote "use strict".

A Small Experiment

★☆☆ Write a class called Book that takes title, author and pages. Its summary() method should return a string like "Frankenstein, Mary Shelley, 280 pages". Create two different books and use === to confirm that their summary methods are the same function.

★★☆ Predict the output before running it:

class Box {
  static created = 0;
  #size;

  constructor(size) {
    this.#size = size;
    Box.created++;
  }

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

const a = new Box(3);
const b = new Box(5);
a.size = 10;
console.log(a.size, b.size, Box.created, a.created);

★★★ Write a SavingsAccount that extends BankAccount. Its constructor should take an interest rate, and an addInterest() method should add the interest to the balance. You'll find that you can't touch #balance directly; work out a solution using the parent's public methods.

Every example in this post ran synchronously: one line finished, the next one started. In the real world, though, code sometimes has to wait for a server to answer or a timer to run out. In the next post we'll see how JavaScript handles that waiting on a single thread without freezing the page, and we'll meet the call stack and the event loop along the way.