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.
- A descriptor can describe a plain value property or an accessor property.
- Getter and setter functions make a property feel like a normal field.
- Descriptors also control metadata such as enumerable and configurable.
- You usually create or change descriptors with Object.defineProperty().
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
- Formatting display values such as a full name, currency text, or a label derived from several fields.
- Validating assignments so invalid data never reaches internal state.
- Creating read-only or write-controlled properties that still look like regular object properties.
- Exposing computed values on classes or prototypes for reuse across many instances.
- Hiding internal storage behind an API that is easier to use than direct state access.
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
- A property cannot be both a data property and an accessor property at the same time.
- Getter and setter functions are usually non-enumerable when created in an object literal, but descriptor flags can change visibility when using Object.defineProperty().
- If you define only a getter, assignment may silently fail in sloppy mode or throw in strict mode.
- Accessors on prototypes are shared across instances, which is useful for memory but can surprise beginners who expect each instance to own its own function.
- Describing a property on a non-configurable object can fail if you later try to redefine it.
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
- Property descriptors define how an object property behaves, not just what it stores.
- Getter functions run when a property is read.
- Setter functions run when a property is assigned.
- Object.defineProperty() gives you precise control over accessor behavior and descriptor flags.
- Use accessors for computed values, validation, and clean APIs.
11. Practice Exercise
Create an object called playlist with these requirements:
- Store the songs internally in an array called _songs.
- Expose a getter named count that returns how many songs are in the playlist.
- Expose a getter named firstSong that returns the first song or null if there are none.
- Expose a setter named addSong that appends a non-empty trimmed string to the array.
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 GreenThis 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.