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 read

A 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)); // '{}'
FlagWhen false
writableassignment is rejected (silently in sloppy mode, TypeError in strict mode)
enumerablehidden from Object.keys, for...in, spread and JSON.stringify
configurablecannot 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);    // 1

Module 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.create or defineProperties
`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.twice is now a plain value, so later changes to o.n cannot 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 flags true.
Which flag hides a property from JSON.stringify and spread?
enumerable: false — enumerable: false removes it from Object.keys, for...in, spread and JSON serialisation. It remains readable by name and visible to Object.getOwnPropertyNames.

Common mistakes

  • Forgetting that defineProperty defaults all three flags to false, 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.
  • defineProperty defaults every flag to false, assignment defaults them to true.
  • enumerable: false hides a property from iteration, spread and JSON, but not from a direct read.
  • To copy an object faithfully, copy its descriptors, not its values.