Getters, Setters and Descriptors
Mental model: Every property is a small record of settings. A getter is a property whose value is a function call.
Level: intermediate · about 11 minutes
const cart = {
items: [{ price: 10 }, { price: 5 }],
get total() {
return this.items.reduce((sum, i) => sum + i.price, 0);
},
};
console.log(cart.total); // 15, no parentheses
cart.items.push({ price: 5 });
console.log(cart.total); // 20, recomputed on every readA getter looks like a property and runs like a function.
An accessor property stores a get function, a set function, or both, instead of a value. Callers cannot tell the difference from the outside, which is what makes accessors useful: you can turn a stored value into a computed one without changing a single call site.
const user = {
_email: '',
get email() { return this._email; },
set email(value) {
if (!value.includes('@')) throw new TypeError('not an email');
this._email = value.toLowerCase();
},
};
user.email = 'ADA@Example.com';
console.log(user.email); // 'ada@example.com'A setter validates on the way in.
Object.defineProperty: the low level form
const config = {};
Object.defineProperty(config, 'version', {
value: '1.0.0',
writable: false,
enumerable: false,
configurable: false,
});
console.log(config.version); // '1.0.0'
console.log(Object.keys(config)); // [] , hidden from iteration
console.log(JSON.stringify(config)); // '{}'
| Flag | When false |
|---|---|
writable | assignment is rejected (silently in sloppy mode, TypeError in strict mode) |
enumerable | hidden from Object.keys, for...in, spread and JSON.stringify |
configurable | cannot be deleted or redefined, and the flags are locked |
'use strict';
const frozenish = {};
Object.defineProperty(frozenish, 'id', { value: 1 });
try {
frozenish.id = 2;
} catch (err) {
console.log(err.name); // 'TypeError'
}
console.log(frozenish.id); // 1Module code is strict, so a rejected write throws rather than passing quietly.
Reading the settings back
const point = { x: 1, get double() { return this.x * 2; } };
console.log(Object.getOwnPropertyDescriptor(point, 'x'));
// { value: 1, writable: true, enumerable: true, configurable: true }
console.log(Object.keys(Object.getOwnPropertyDescriptor(point, 'double')));
// ['get', 'set', 'enumerable', 'configurable'] -- no value, no writable
`getOwnPropertyDescriptor(o, k)`- one property, as a settings object
`getOwnPropertyDescriptors(o)`- all of them, ready to feed to
Object.createordefineProperties `getOwnPropertyNames(o)`- own string keys including non-enumerable ones
`defineProperties(o, map)`- define several at once
Computed accessor names
const field = 'temperature';
const reading = {
celsius: 21,
get [field]() { return `${this.celsius}C`; },
get [`${field}F`]() { return `${this.celsius * 9 / 5 + 32}F`; },
};
console.log(reading.temperature); // '21C'
console.log(reading.temperatureF); // '69.8F'
const o = {};
Object.defineProperty(o, 'a', { value: 1, enumerable: false });
o.b = 2;
console.log(Object.keys(o), JSON.stringify(o), o.a);Non-enumerable properties are skipped by Object.keys and JSON.stringify, but they are still readable by name. Hidden is not the same as absent.
Try it yourself
Inspect the flags
const account = { owner: 'Ada' };
Object.defineProperty(account, 'id', { value: 'acc_1', enumerable: false });
Object.defineProperty(account, 'balance', { value: 100, writable: true, enumerable: true });
console.log(Object.keys(account)); // ['owner', 'balance']
console.log(Object.getOwnPropertyNames(account)); // ['owner', 'id', 'balance']
console.log(Object.getOwnPropertyDescriptor(account, 'id'));
account.balance = 150;
console.log(account.balance); // 150
console.log(JSON.stringify(account));
Add a fourth property with configurable: false and try to delete it. Then try to redefine it.
Exercises
Define a hidden constant
Write defineHidden(target, key, value) which adds a property that can be read by name, is invisible to Object.keys and JSON.stringify, rejects reassignment, and cannot be deleted or redefined. Return the same object you were given.
Add a live fullName accessor
Write withFullName(person) which defines a non-enumerable fullName accessor on person. Reading it returns "first last" from the current values. Writing "Grace Hopper" splits on the first space and updates first and last. Return the same object.
Check yourself
- What does this log?
- 2 — Spread invoked the getter once, at copy time, and stored the resulting number 2.
copy.twiceis now a plain value, so later changes too.ncannot affect it. - A property defined with
Object.defineProperty(o, "k", { value: 1 })is… - non-writable, non-enumerable and non-configurable — Every flag you omit from a descriptor defaults to
false. Assignment (o.k = 1) is the opposite: it creates a property with all three flagstrue. - Which flag hides a property from
JSON.stringifyand spread? enumerable: false—enumerable: falseremoves it fromObject.keys,for...in, spread and JSON serialisation. It remains readable by name and visible toObject.getOwnPropertyNames.
Common mistakes
- Forgetting that
definePropertydefaults all three flags tofalse, then wondering why a write is ignored. - Putting expensive or side-effecting work in a getter that looks like a plain field.
- Expecting spread to carry accessors across. It copies the value the getter returned.
Takeaways
- Accessors let a computed value look exactly like a stored one.
definePropertydefaults every flag tofalse, assignment defaults them totrue.enumerable: falsehides a property from iteration, spread and JSON, but not from a direct read.- To copy an object faithfully, copy its descriptors, not its values.