Skip to content
JavaScript js classes 4 min read

Getters & Setters in Classes

Getters and setters are special methods that look like properties from the outside but run code when accessed or assigned. They let a class expose values that are computed on the fly, validate incoming data before storing it, and hide internal state behind a clean public surface. Used well, they turn a class from a bag of raw fields into an object that controls its own invariants.

Defining accessors with get and set

Inside a class body, prefix a method with get to make it readable as a property, and with set to intercept assignments. The getter takes no parameters; the setter takes exactly one — the value being assigned.

class Temperature {
  #celsius = 0;

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

  set celsius(value) {
    this.#celsius = value;
  }

  get fahrenheit() {
    return this.#celsius * 1.8 + 32;
  }

  set fahrenheit(value) {
    this.#celsius = (value - 32) / 1.8;
  }
}

const t = new Temperature();
t.celsius = 25;
console.log(t.fahrenheit);
t.fahrenheit = 212;
console.log(t.celsius);

Output:

77
100

Notice that fahrenheit is never stored. It is derived from #celsius every time it is read, and writing to it updates the underlying field. To callers, both celsius and fahrenheit behave like ordinary properties.

Computed properties

A getter with no matching setter creates a read-only computed property. This is ideal for values that should always reflect current state rather than be set independently.

class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  get area() {
    return this.width * this.height;
  }

  get isSquare() {
    return this.width === this.height;
  }
}

const r = new Rectangle(4, 4);
console.log(r.area);
console.log(r.isSquare);
r.width = 8;
console.log(r.area);

Output:

16
true
32

Because area recomputes on each access, it can never drift out of sync with width and height. Attempting r.area = 100 would silently fail in non-strict mode and throw a TypeError in strict mode (including module and class bodies).

Validation in setters

Setters shine when you need to guard against invalid input. Centralizing the check in one place means every assignment — from the constructor or from outside — is validated identically.

class Account {
  #balance = 0;

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

  set balance(amount) {
    if (typeof amount !== "number" || Number.isNaN(amount)) {
      throw new TypeError("Balance must be a valid number");
    }
    if (amount < 0) {
      throw new RangeError("Balance cannot be negative");
    }
    this.#balance = amount;
  }
}

const acc = new Account();
acc.balance = 150;
console.log(acc.balance);

try {
  acc.balance = -20;
} catch (err) {
  console.log(`${err.name}: ${err.message}`);
}

Output:

150
RangeError: Balance cannot be negative

Pairing with private fields

The pattern that makes accessors truly safe is backing them with a private field. The field (prefixed with #) is inaccessible from outside the class, so the only way to read or change it is through your getter and setter. This guarantees your validation can never be bypassed.

class User {
  #email;

  constructor(email) {
    this.email = email; // routes through the setter, so it's validated
  }

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

  set email(value) {
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) {
      throw new Error(`Invalid email: ${value}`);
    }
    this.#email = value.toLowerCase();
  }
}

const u = new User("[email protected]");
console.log(u.email);
console.log(Object.keys(u));

Output:

[email protected]
[]

Object.keys(u) is empty because the private field is not an enumerable own property — the public email accessor is defined on the prototype, not the instance.

Tip: Always assign through the setter inside the constructor (this.email = email) rather than writing the private field directly. That way construction-time data passes the same validation as later assignments.

Accessors vs. plain methods

Both can run logic, but they signal different intent. Choose accessors for property-like access and methods for actions.

AspectGetter / SetterRegular method
Call syntaxobj.areaobj.getArea()
Takes argumentsGetter: none, Setter: oneAny number
ImpliesA value / propertyAn action or computation
Side effectsShould be minimalAcceptable
Defined onPrototypePrototype

Warning: Avoid expensive work or visible side effects in a getter. Callers reasonably assume reading a property is cheap, so heavy computation or network calls there leads to surprising performance bugs. Use a method like loadProfile() for that instead.

Inheritance and super

Accessors live on the prototype, so subclasses inherit them and can override either half. A subclass setter can delegate to the parent’s via super.

class PositiveAccount extends Account {
  set balance(amount) {
    if (amount === 0) {
      throw new RangeError("Balance must be positive");
    }
    super.balance = amount; // reuse parent validation
  }
}

If you override only the setter, the inherited getter is lost in that subclass unless you redeclare it — accessors are paired by property name, and defining one resets the descriptor.

Best practices

  • Back accessors with private fields (#field) so validation cannot be bypassed.
  • Keep getters cheap and free of observable side effects.
  • Use a read-only getter (no setter) for any value derived from other state.
  • Run all validation in the setter and assign through it from the constructor.
  • Throw TypeError / RangeError with clear messages for invalid input.
  • Prefer a named method over an accessor when the operation is an action, takes arguments, or is expensive.
  • When overriding one accessor in a subclass, redeclare both halves to avoid losing the inherited one.
Last updated June 1, 2026
Was this helpful?