JavaScript Getters Setters

Getters and setters are special methods that let you run code whenever a property is read or written, while the outside world still sees a plain property. Instead of calling person.getFullName(), you write person.fullName and JavaScript quietly runs a function behind the scenes. This lets you validate data, compute derived values on the fly, and hide internal implementation details without changing how your objects are used.

Overview / How It Works

In JavaScript, every property on an object is either a data property (a plain value) or an accessor property (a pair of functions: a get function and/or a set function). When you access obj.prop, the engine checks whether prop is an accessor property. If it is, the engine calls the getter function and uses its return value as the result. When you write obj.prop = value, the engine calls the setter function with value as its argument instead of storing the value directly.

This means a getter or setter looks exactly like a normal property from the caller’s perspective — there is no special syntax needed to use one. That is the whole point: you can start with a simple data property, and later swap it for a getter/setter pair (to add validation, logging, or computed logic) without breaking any code that already reads or writes that property.

Getters and setters can appear in three places: object literals, classes, and via Object.defineProperty(). In an object literal, accessor properties are enumerable (they show up in for...in loops and Object.keys()) by default. In a class body, getters and setters are defined on the prototype, not on individual instances, and they are non-enumerable by default — so they will not show up in Object.keys(instance) or a naive JSON.stringify of the instance’s own keys, but they will still run when accessed directly. JSON.stringify() does invoke getters when serializing an object, because it reads each property by accessing it (which triggers the getter), then serializes the returned value.

A getter takes no parameters and must return a value. A setter takes exactly one parameter (the new value) and conventionally does not return anything meaningful (its return value is ignored when you do obj.prop = value). You can define a getter without a setter to create a read-only property, or a setter without a getter to create a write-only one.

Syntax

class ClassName {
  get propertyName() {
    // compute and return a value
    return this._value;
  }

  set propertyName(newValue) {
    // validate, then store newValue
    this._value = newValue;
  }
}
Form Where it’s used Notes
get name() {} Object literals & classes Defines a read accessor; called with no arguments when the property is read.
set name(value) {} Object literals & classes Defines a write accessor; called with exactly one argument when the property is assigned.
Object.defineProperty(obj, 'name', { get, set }) Any object, dynamically Lets you also control enumerable and configurable flags.
Object.defineProperties(obj, {...}) Any object, dynamically Defines several accessor properties in one call.

Examples

Example 1: A computed full name

const person = {
  firstName: 'Ada',
  lastName: 'Lovelace',
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  },
  set fullName(value) {
    const [first, last] = value.split(' ');
    this.firstName = first;
    this.lastName = last;
  }
};

console.log(person.fullName);
person.fullName = 'Grace Hopper';
console.log(person.firstName, person.lastName);
Ada Lovelace
Grace Hopper

Reading person.fullName runs the getter, which combines firstName and lastName into one string. Assigning a new string to person.fullName runs the setter, which splits the string and updates the two underlying fields. Notice that fullName itself is never stored anywhere — it’s computed on demand.

Example 2: Validation with a class

class Circle {
  constructor(radius) {
    this._radius = radius;
  }

  get radius() {
    return this._radius;
  }

  set radius(value) {
    if (typeof value !== 'number' || value <= 0) {
      throw new RangeError('Radius must be a positive number');
    }
    this._radius = value;
  }

  get area() {
    return Number((Math.PI * this._radius ** 2).toFixed(2));
  }
}

const circle = new Circle(5);
console.log(circle.radius);
console.log(circle.area);

circle.radius = 10;
console.log(circle.area);

try {
  circle.radius = -3;
} catch (error) {
  console.log(error.message);
}
5
78.54
314.16
Radius must be a positive number

Here radius is backed by a real field, _radius (the leading underscore is just a convention meaning “treat this as internal”). The setter rejects invalid values before they ever reach _radius, and area is a read-only getter with no matching setter — it is always recalculated from the current radius, so it can never go stale.

Example 3: Private fields with accessors

class BankAccount {
  #balance;

  constructor(owner, initialBalance = 0) {
    this.owner = owner;
    this.#balance = initialBalance;
  }

  get balance() {
    return `$${this.#balance.toFixed(2)}`;
  }

  set balance(value) {
    throw new Error('Use deposit() or withdraw() to change the balance');
  }

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

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

const account = new BankAccount('Miguel', 100);
console.log(account.balance);
console.log(account.deposit(50));

try {
  account.balance = 1000000;
} catch (error) {
  console.log(error.message);
}
$100.00
$150.00
Use deposit() or withdraw() to change the balance

This combines a truly private field (#balance, accessible only inside the class) with a getter that formats it for display and a setter that always throws, forcing every balance change to go through deposit() or withdraw(). This is a common real-world pattern: expose a read-only view of private state while funneling all mutation through controlled methods.

Under the Hood: Object.defineProperty

Class and object-literal syntax for getters/setters are really just convenient shorthand for what Object.defineProperty() does at a lower level. Using it directly gives you fine control, including sharing one piece of state between two accessor properties:

const temperature = {};
let celsius = 0;

Object.defineProperty(temperature, 'celsius', {
  get() {
    return celsius;
  },
  set(value) {
    celsius = value;
  },
  enumerable: true,
  configurable: true
});

Object.defineProperty(temperature, 'fahrenheit', {
  get() {
    return celsius * 9 / 5 + 32;
  },
  set(value) {
    celsius = (value - 32) * 5 / 9;
  },
  enumerable: true
});

temperature.celsius = 25;
console.log(temperature.fahrenheit);

temperature.fahrenheit = 98.6;
console.log(temperature.celsius.toFixed(1));
77
37.0

Step by step: assigning temperature.celsius = 25 calls the celsius setter, which stores 25 in the shared closure variable celsius. Reading temperature.fahrenheit calls the fahrenheit getter, which reads that same closure variable and converts it. Because both accessors close over one shared variable instead of two separate object properties, the two views of the temperature can never disagree with each other. This is exactly the mechanism the engine uses internally whenever you write get/set inside a class or object literal — the syntax just saves you from calling Object.defineProperty by hand.

Common Mistakes

Mistake 1: Calling a getter like a method

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

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

const rect = new Rectangle(4, 5);

try {
  console.log(rect.area());
} catch (error) {
  console.log(error.message);
}

console.log(rect.area);
rect.area is not a function
20

Because area is an accessor, not a method, calling it with parentheses tries to invoke whatever value the getter returns (a number, here) as a function, which throws a TypeError. The fix is to access it like any other property: rect.area, with no parentheses.

Mistake 2: Giving the getter/setter the same name as its backing field

A very common bug is writing get value() { return this.value; } or set value(v) { this.value = v; } using the exact same name inside the accessor’s own body. Reading this.value inside the value getter triggers the getter again, which reads this.value again, and so on — an infinite loop that crashes with RangeError: Maximum call stack size exceeded. The fix is to store the real data under a different name, such as this._value or a private field #value, and have the getter/setter read and write that instead of themselves.

Best Practices

  • Back a getter/setter pair with a differently-named field (_value or, better, a true private field like #value) so you never trigger recursive calls.
  • Use private fields (#field) instead of underscore-prefixed public fields when you actually want to prevent external code from bypassing your setter’s validation.
  • Keep getters cheap and side-effect free — callers expect reading a property to be fast and safe to do repeatedly, unlike calling a method.
  • Use setters to centralize validation once, instead of repeating if checks everywhere a property is assigned.
  • Prefer a getter-only (no setter) property to express “this is computed, and read-only” — assigning to it will silently fail in non-strict mode or throw a TypeError in strict mode/classes, which is exactly the feedback you want.
  • Don’t overuse getters/setters for simple data holders; if a property never needs validation or computation, a plain field is simpler and faster.
  • Remember class accessors are defined on the prototype and are non-enumerable by default, so relying on Object.keys(instance) to include them will not work as it might with object-literal accessors.

Practice Exercises

  • Create a Temperature class with a private #kelvin field and celsius/fahrenheit getter and setter pairs that all read and write through the same underlying Kelvin value.
  • Create a Product class with a price setter that throws a RangeError if the new price is negative, and a formattedPrice getter that returns the price as a string prefixed with '$' and fixed to two decimals.
  • Given an object literal representing a rectangle with width and height, add a get perimeter() accessor. Verify that changing width afterward automatically changes the value returned by perimeter on the next read.

Summary

  • Getters (get) and setters (set) are accessor properties that run functions when a property is read or assigned, while looking like ordinary properties to the caller.
  • They can be defined in object literals, in class bodies, or dynamically with Object.defineProperty().
  • A getter takes no arguments and returns a value; a setter takes exactly one argument and typically stores it.
  • Class accessors live on the prototype and are non-enumerable by default; object-literal accessors are enumerable by default.
  • Always back an accessor with a differently-named field to avoid infinite recursion.
  • Use setters for validation and getters for read-only or computed values, keeping internal state private with #fields when it matters.