Skip to content
academia.sh

Lesson 04 / 17

Property Descriptors

The writability, enumerability, and configurability flags a property carries alongside its value; accessor properties and copying that preserves descriptors.

Contents

The previous lesson arrived at the same place twice: class methods do not appear in for...in output, manually written assignments do; and reading, though written to look like a read, ran a function. Both are the result of a single fact.

A property is not made up of just a “name → value” pair. Alongside every property stands a property descriptor that determines its behavior. This lesson opens the descriptor’s fields one by one, then takes up accessor properties, which carry a function in place of a value.

Reading the Descriptor

Object.getOwnPropertyDescriptor returns the descriptor of an object’s own property. Data properties carry four fields: value, writable, enumerable, configurable.

function flags(obj, name) {
  const descriptor = Object.getOwnPropertyDescriptor(obj, name);
  if (descriptor === undefined) return `${name}: no descriptor`;
  if ("value" in descriptor) {
    return `${name}: data, writable=${descriptor.writable}, enumerable=${descriptor.enumerable}, configurable=${descriptor.configurable}`;
  }
  return `${name}: accessor, getter=${typeof descriptor.get}, setter=${typeof descriptor.set}, enumerable=${descriptor.enumerable}`;
}

class MeasurementRecord {
  constructor(sensor, value) {
    this.sensor = sensor;
    this.value = value;
  }
  format() {
    return `${this.sensor}: ${this.value}`;
  }
}

const record = new MeasurementRecord("S-01", 21.4);
const plainRecord = { sensor: "S-02", value: 19.8 };

Object.defineProperty(plainRecord, "unit", { value: "celsius" });

console.log(flags(record, "sensor"));
console.log(flags(MeasurementRecord.prototype, "format"));
console.log(flags(plainRecord, "sensor"));
console.log(flags(plainRecord, "unit"));
console.log(flags(record, "format"));
sensor: data, writable=true, enumerable=true, configurable=true
format: data, writable=true, enumerable=false, configurable=true
sensor: data, writable=true, enumerable=true, configurable=true
unit: data, writable=false, enumerable=false, configurable=false
format: no descriptor

Three rules follow. Properties produced by ordinary assignment or an object literal have all three flags open. Class methods are defined as non-enumerable — this is the source of the for...in difference from the previous lesson. In an Object.defineProperty call, every flag not specified is counted as closed; this is why the unit property comes out non-writable, non-enumerable, and non-configurable. The last line is a reminder of the word “own” in the function’s name: because format stands on the prototype, the record has no definition of it on itself.

Writability

If the writable flag is closed, assignment does not change the value. There are two different forms of not changing, and the difference depends on whether the code runs in strict mode.

const record = { sensor: "S-01", value: 21.4 };

Object.defineProperty(record, "unit", {
  value: "celsius",
  writable: false,
  enumerable: true,
  configurable: false,
});

console.log(record.unit);

try {
  record.unit = "fahrenheit";
} catch (error) {
  console.log(`${error.constructor.name} caught`);
}
console.log(record.unit);

function nonStrictWrite(obj) {
  return new Function("n", "n.unit = 'fahrenheit'; return n.unit;")(obj);
}
console.log(nonStrictWrite(record));

try {
  Object.defineProperty(record, "unit", { value: "kelvin" });
} catch (error) {
  console.log(`${error.constructor.name} caught`);
}
console.log(record.unit);
celsius
TypeError caught
celsius
celsius
TypeError caught
celsius

Module files and class bodies run in strict mode; there, assigning to a non-writable property throws an error. The body produced with new Function is not in strict mode, so the same assignment silently fails — the unchanged value on the fourth line shows this. Because silent failure lets an error be noticed long after the assignment, strict mode is preferred.

The last two lines are the effect of the configurable flag. Once configurability is closed, the descriptor can never be changed again and the property cannot be deleted; this is the one flag that cannot be reversed. For this reason, configurable: false should be a deliberate decision.

Enumerability

The enumerable flag determines whether a property enters listings. Its effect spreads across a wide set of operations.

const record = { sensor: "S-01", value: 21.4 };

Object.defineProperty(record, "sourceFile", {
  value: "measurement-2.csv",
  writable: true,
  enumerable: false,
  configurable: true,
});

console.log(record.sourceFile);
console.log(Object.keys(record).join(","));
console.log(Object.getOwnPropertyNames(record).join(","));
console.log(JSON.stringify(record));
console.log(JSON.stringify({ ...record }));
console.log(JSON.stringify(Object.assign({}, record)));
console.log("sourceFile" in record);
console.log(Object.entries(record).length);
measurement-2.csv
sensor,value
sensor,value,sourceFile
{"sensor":"S-01","value":21.4}
{"sensor":"S-01","value":21.4}
{"sensor":"S-01","value":21.4}
true
2

The property is readable and the in operator sees it; but Object.keys, Object.entries, JSON.stringify, object spread, and Object.assign skip it. Object.getOwnPropertyNames does not look at enumerability, which is why it is the way to inspect properties that stay hidden.

A practical rule follows from this: information that accompanies data without being part of the data — a source file name, internal counters, cache fields — when defined as non-enumerable, does not automatically get caught up in serialization and copying operations. Symbol-keyed properties give the same result too; they also do not enter Object.keys and JSON.stringify output.

Accessor Properties

The second descriptor kind carries get and set functions in place of value and writable. A property like this is called an accessor: reading runs the get function, writing runs the set function.

An accessor’s value is presenting a computed magnitude as if it were a property. In a measurement record, the read value is the sum of the raw value and the calibration offset; making this an accessor instead of a method leaves the caller unaware that a computation exists at all.

const measurementBehavior = {};

Object.defineProperty(measurementBehavior, "reading", {
  get() {
    return this.value + this.offset;
  },
  set(newValue) {
    if (typeof newValue !== "number" || Number.isNaN(newValue)) {
      throw new TypeError("the reading must be a number");
    }
    this.value = newValue - this.offset;
  },
  enumerable: false,
  configurable: true,
});

function measurementRecord(sensor, value, offset) {
  const record = Object.create(measurementBehavior);
  record.sensor = sensor;
  record.value = value;
  record.offset = offset;
  return record;
}

const record = measurementRecord("S-01", 21.4, -0.4);

console.log(record.reading);
record.reading = 25;
console.log(record.value);
console.log(record.reading);

try {
  record.reading = "twenty";
} catch (error) {
  console.log(`${error.constructor.name}: ${error.message}`);
}

console.log(Object.hasOwn(record, "reading"));
console.log(Object.hasOwn(measurementBehavior, "reading"));
console.log(JSON.stringify(record));
21
25.4
25
TypeError: the reading must be a number
false
true
{"sensor":"S-01","value":25.4,"offset":-0.4}

The accessor is defined on the prototype; it is not the record’s own property. Even so, the this inside the get and set bodies is bound to the object the access is made on — that is, the record itself. A function found on the chain works with the data of the object it is called on; the full rule for this will be covered in the first lesson of the next topic.

The setter function is the direct way of preserving the class invariant introduced in the Introduction to Object-Oriented Programming lesson of the Programming Fundamentals course: an invalid value never enters the object at all. Also, because the accessor is defined as non-enumerable, it does not enter serialization; the last line shows only the raw data fields. This keeps computed values from mixing with stored data.

The get and set keywords in a class body are shorthand for this same definition; the accessor they produce is also written to the prototype object, as non-enumerable.

Copying That Preserves Descriptors

The usual ways of copying an object do not carry descriptors. Object spread and Object.assign read every property on the source and write it to the target as an ordinary data property. Accessors turn into a frozen value in this operation.

const source = {
  sensor: "S-01",
  value: 21.4,
  get label() {
    return `${this.sensor}/${this.value}`;
  },
};

const spread = { ...source };
const definedCopy = Object.defineProperties(
  Object.create(Object.getPrototypeOf(source)),
  Object.getOwnPropertyDescriptors(source),
);

console.log(source.label);
console.log(spread.label);
console.log(definedCopy.label);

source.value = 30;
spread.value = 30;
definedCopy.value = 30;

console.log(source.label);
console.log(spread.label);
console.log(definedCopy.label);

console.log(typeof Object.getOwnPropertyDescriptor(spread, "label").get);
console.log(typeof Object.getOwnPropertyDescriptor(definedCopy, "label").get);
S-01/21.4
S-01/21.4
S-01/21.4
S-01/30
S-01/21.4
S-01/30
undefined
function

In the copy produced by spread, label is no longer computed; it is a fixed data property carrying the text from the moment of copying, which is why it keeps showing the old one once value changes. When Object.getOwnPropertyDescriptors is used together with Object.defineProperties, both accessors and flags are preserved; the Object.create call also carries the prototype link. Copying’s general problem — which information is carried, which is dropped — will be taken up in full in the course’s last lesson.

Summary

  • Every property carries writable, enumerable, configurable flags alongside its value; Object.getOwnPropertyDescriptor reads them.
  • Flags not specified in an Object.defineProperty call are counted as closed; properties produced by assignment have all three open.
  • Assigning to a non-writable property throws an error in strict mode, silently fails in non-strict mode; configurable: false cannot be reversed.
  • Non-enumerable properties do not enter Object.keys, Object.entries, JSON.stringify, spread, and Object.assign results; they are visible with Object.getOwnPropertyNames.
  • On an accessor property, reading and writing each run a function; the setter preserves the class invariant by keeping an invalid value from entering the object.
  • Spread and Object.assign do not carry descriptors; they turn an accessor into a fixed value.

Next Step

writable: false locks a single property. Preventing an entire object from changing, forbidding new properties from being added to it, or leaving only existing fields’ values changeable is tedious to write per property. The next lesson takes up the freezing and sealing operations applied to a whole object, how deep they go, and the patterns for producing a new object instead of a change when change is needed.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close