JavaScript Property Descriptors: Getter and Setter Basics

JavaScript property descriptors let you control how an object property behaves, including whether it is readable, writable, enumerable, configurable, or computed with a getter and setter. They are an important part of how objects work under the hood, and they help you build cleaner APIs, derived values, and validation logic.

Quick answer: A property descriptor is the object that describes a property’s behavior. Use get and set when you want a property to act like a field but run custom code when it is read or written.

Difficulty: Intermediate

You'll understand this better if you know: basic objects, functions, and how dot notation reads and writes properties.

1. What Is JavaScript Property Descriptors: Getter and Setter Basics?

A property descriptor is a record that tells JavaScript how a property should behave. For ordinary properties, the value is stored directly. For accessor properties, JavaScript runs a getter when the property is read and a setter when it is assigned.

In short, descriptors let you define not just what a property stores, but how it behaves.

2. Why JavaScript Property Descriptors Matter

Descriptors matter because many JavaScript APIs rely on them, and they give you precise control over object design. They are useful when you want to validate input, derive a value from other data, hide implementation details, or expose a property that looks simple to users of your code.

You should use them when a plain property is not enough. If you only need a field that stores a value, a regular property is simpler. If you need computed logic, controlled writes, or metadata control, descriptors are the right tool.

3. Basic Syntax or Core Idea

The two most common descriptor forms are data descriptors and accessor descriptors. The accessor form uses get and set.

Accessor descriptor syntax

Here is the minimal pattern for a getter and setter on an object literal.

const user = {
  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;
  }
};

This object exposes a property called fullName, but reading it calls the getter and assigning to it calls the setter.

Descriptor shape with Object.defineProperty()

You can also define a property directly with a descriptor object.

const person = {};

Object.defineProperty(person, "age", {
  get() {
    return 21;
  },
  set(value) {
    // custom write logic
  },
  enumerable: true,
  configurable: true
});

Accessor descriptors use functions for behavior, while data descriptors use value and writable.

4. Step-by-Step Examples

Example 1: Derived full name

This example shows a property that is built from two stored values. The getter combines data, so callers do not need to know how the value is assembled.

const user = {
  firstName: "Grace",
  lastName: "Hopper",

  get fullName() {
    return this.firstName + " " + this.lastName;
  }
};

console.log(user.fullName);

Reading user.fullName runs the getter and returns the combined string.

Example 2: Splitting a single value into parts

A setter can accept one input and update multiple internal fields. This is useful for parsing user input into normalized pieces.

const user = {
  firstName: "",
  lastName: "",

  set fullName(value) {
    const [first = "", last = ""] = value.trim().split(/\s+/);
    this.firstName = first;
    this.lastName = last;
  }
};

user.fullName = "Alan Turing";
console.log(user.firstName);

Assigning to fullName triggers the setter and updates the underlying fields.

Example 3: Validation inside a setter

Setters are a convenient place to reject invalid values before they enter your object state.

const account = {
  _balance: 0,

  get balance() {
    return this._balance;
  },

  set balance(value) {
    if (value < 0) {
      throw new RangeError("Balance cannot be negative");
    }
    this._balance = value;
  }
};

The setter protects the object from invalid data, while the getter provides a simple read-only interface.

Example 4: Defining a computed property with Object.defineProperty()

This form is useful when you want to attach an accessor after the object already exists.

const cart = {
  items: [12, 8, 5]
};

Object.defineProperty(cart, "total", {
  get() {
    return this.items.reduce((sum, item) => sum + item, 0);
  },
  enumerable: true
});

console.log(cart.total);

The total property behaves like a field, but it always reflects the current items array.

5. Practical Use Cases

6. Common Mistakes

Mistake 1: Expecting a setter to run when a property is read

Getters and setters have opposite jobs. A getter runs when the property is accessed, and a setter runs when a value is assigned.

Problem: This code reads the property but expects the setter to transform the value. Nothing happens because assignment never occurs.

const profile = {
  set name(value) {
    this._name = value.trim();
  }
};

console.log(profile.name);

Fix: Add a getter for reads, and keep the setter for writes.

const profile = {
  _name: "Ada",
  get name() {
    return this._name;
  },
  set name(value) {
    this._name = value.trim();
  }
};

The fixed version works because reads and writes now have separate behavior.

Mistake 2: Defining both value and get or set

A descriptor can be either a data descriptor or an accessor descriptor, not both. Mixing them causes a TypeError.

Problem: This descriptor is invalid because it combines a stored value with accessor functions.

const person = {};

Object.defineProperty(person, "age", {
  value: 30,
  get() {
    return 30;
  }
});

Fix: Use either a plain value descriptor or an accessor descriptor.

const person = {};

Object.defineProperty(person, "age", {
  get() {
    return 30;
  },
  enumerable: true
});

The corrected descriptor is valid because it uses accessor behavior only.

Mistake 3: Losing the correct this value

Accessor functions usually depend on this. If you detach the getter or setter and call it as a plain function, the object context is lost.

Problem: This code calls a getter function without the object, so this is not the original object and the result is wrong.

const box = {
  width: 10,
  get area() {
    return this.width * 2;
  }
};

const getter = Object.getOwnPropertyDescriptor(box, "area").get;
console.log(getter());

Fix: Read the property through the object, or bind the function if you truly need to call it later.

const box = {
  width: 10,
  get area() {
    return this.width * 2;
  }
};

console.log(box.area);

The fixed version works because the getter runs with the correct object context.

Mistake 4: Forgetting that a getter without a setter is read-only

A property with only a getter can be read, but assignment to it does not update the underlying value. In strict mode, this can throw an error.

Problem: This code tries to assign to a getter-only property, which cannot store a new value.

"use strict";

const settings = {
  get theme() {
    return "dark";
  }
};

settings.theme = "light";

Fix: Add a setter if the property should be writable, or keep it read-only on purpose.

"use strict";

const settings = {
  _theme: "dark",
  get theme() {
    return this._theme;
  },
  set theme(value) {
    this._theme = value;
  }
};

The corrected version works because the property now supports assignment.

7. Best Practices

Use getters for derived values, not expensive side effects

Getters should feel like property access, so they are best for quick, predictable calculations. If reading a property triggers network requests, DOM changes, or large computations, the property becomes surprising to use.

const cart = {
  items: [5, 9],
  get total() {
    return this.items.reduce((sum, item) => sum + item, 0);
  }
};

This is a good fit because total is a derived value that is cheap to compute.

Use setters to validate and normalize input

Setters work well when you want one consistent write path. You can trim strings, clamp numbers, or reject invalid values before they reach your object state.

const profile = {
  _email: "",
  set email(value) {
    const normalized = value.trim().toLowerCase();
    this._email = normalized;
  }
};

This keeps the rest of your code from repeating the same cleanup logic everywhere.

Prefer descriptors when the public API should stay simple

A property can hide complexity behind a friendly name. That makes calling code easier to read and reduces the need for helper methods.

const rectangle = {
  width: 4,
  height: 6,
  get area() {
    return this.width * this.height;
  }
};

Callers read rectangle.area instead of invoking a method for a value that behaves like a field.

8. Limitations and Edge Cases

If a getter seems to run unexpectedly often, remember that any property read triggers it. Repeated access inside loops can repeat the computation many times.

9. Practical Mini Project

In this mini project, we will build a small temperature record object. It stores Celsius internally, exposes Fahrenheit through a getter, and accepts Fahrenheit through a setter.

const thermometer = {
  _celsius: 0,

  get celsius() {
    return this._celsius;
  },

  set celsius(value) {
    this._celsius = value;
  },

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

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

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

thermometer.fahrenheit = 98.6;
console.log(thermometer.celsius);

This shows a realistic accessor pattern: one internal value, two public views, and a single place for conversion logic.

10. Key Points

11. Practice Exercise

Create an object called playlist with these requirements:

Expected output: After adding two songs, count should be 2 and firstSong should return the first track name.

Hint: Use a setter for the append action and a getter for each computed read-only value.

const playlist = {
  _songs: [],

  get count() {
    return this._songs.length;
  },

  get firstSong() {
    return this._songs[0] ?? null;
  },

  set addSong(value) {
    const song = value.trim();
    if (song === "") {
      throw new Error("Song name cannot be empty");
    }
    this._songs.push(song);
  }
};

playlist.addSong = "  Blue in Green  ";
playlist.addSong = "So What";

console.log(playlist.count);       // 2
console.log(playlist.firstSong);  // Blue in Green

This solution works because the setter handles writes and the getters expose derived values without duplicating state.

12. Final Summary

JavaScript property descriptors describe how properties behave, and accessors give you a way to run code when a property is read or written. They are especially useful for computed values, validation, and keeping your public API simple while protecting internal state.

When you use getters and setters well, your objects become easier to use and harder to misuse. Start with a plain property when you can, then reach for descriptors when you need more control over reads, writes, or property metadata.

A good next step is to learn the rest of the descriptor flags, especially enumerable, configurable, and how they affect Object.keys(), for...in, and object redefinition.