Deep JavaScript
Please support this book: buy it or donate
(Ad, please don’t block.)

10 Properties: assignment vs. definition



There are two ways of creating or changing a property prop of an object obj:

This chapter explains how they work.

  Required knowledge: property attributes and property descriptors

For this chapter, you should be familiar with property attributes and property descriptors. If you aren’t, check out §8 “Property attributes: an introduction”.

10.1 Assignment vs. definition

10.1.1 Assignment

We use the assignment operator = to assign a value value to a property .prop of an object obj:

obj.prop = value

This operator works differently depending on what .prop looks like:

That is, the main purpose of assignment is making changes. That’s why it supports setters.

10.1.2 Definition

To define a property with the key propKey of an object obj, we use an operation such as the following method:

Object.defineProperty(obj, propKey, propDesc)

This method works differently depending on what the property looks like:

That is, the main purpose of definition is to create an own property (even if there is an inherited setter, which it ignores) and to change property attributes.

10.2 Assignment and definition in theory (optional)

  Property descriptors in the ECMAScript specification

In specification operations, property descriptors are not JavaScript objects but Records, a spec-internal data structure that has fields. The keys of fields are written in double brackets. For example, Desc.[[Configurable]] accesses the field .[[Configurable]] of Desc. These records are translated to and from JavaScript objects when interacting with the outside world.

10.2.1 Assigning to a property

The actual work of assigning to a property is handled via the following operation in the ECMAScript specification:

OrdinarySetWithOwnDescriptor(O, P, V, Receiver, ownDesc)

These are the parameters:

The return value is a boolean that indicates whether or not the operation succeeded. As explained later in this chapter, strict-mode assignment throws a TypeError if OrdinarySetWithOwnDescriptor() fails.

This is a high-level summary of the algorithm:

In more detail, this algorithm works as follows:

10.2.1.1 How do we get from an assignment to OrdinarySetWithOwnDescriptor()?

Evaluating an assignment without destructuring involves the following steps:

Notably, PutValue() throws a TypeError in strict mode if the result of .[[Set]]() is false.

10.2.2 Defining a property

The actual work of defining a property is handled via the following operation in the ECMAScript specification:

ValidateAndApplyPropertyDescriptor(O, P, extensible, Desc, current)

The parameters are:

The result of the operation is a boolean that indicates if it succeeded. Failure can have different consequences. Some callers ignore the result. Others, such as Object.defineProperty(), throw an exception if the result is false.

This is a summary of the algorithm:

10.3 Definition and assignment in practice

This section describes some consequences of how property definition and assignment work.

10.3.1 Only definition allows us to create a property with arbitrary attributes

If we create an own property via assignment, it always creates properties whose attributes writable, enumerable, and configurable are all true.

const obj = {};
obj.dataProp = 'abc';
assert.deepEqual(
  Object.getOwnPropertyDescriptor(obj, 'dataProp'),
  {
    value: 'abc',
    writable: true,
    enumerable: true,
    configurable: true,
  });

Therefore, if we want to specify arbitrary attributes, we must use definition.

And while we can create getters and setters inside object literals, we can’t add them later via assignment. Here, too, we need definition.

10.3.2 The assignment operator does not change properties in prototypes

Let us consider the following setup, where obj inherits the property prop from proto.

const proto = { prop: 'a' };
const obj = Object.create(proto);

We can’t (destructively) change proto.prop by assigning to obj.prop. Doing so creates a new own property:

assert.deepEqual(
  Object.keys(obj), []);

obj.prop = 'b';

// The assignment worked:
assert.equal(obj.prop, 'b');

// But we created an own property and overrode proto.prop,
// we did not change it:
assert.deepEqual(
  Object.keys(obj), ['prop']);
assert.equal(proto.prop, 'a');

The rationale for this behavior is as follows: Prototypes can have properties whose values are shared by all of their descendants. If we want to change such a property in only one descendant, we must do so non-destructively, via overriding. Then the change does not affect the other descendants.

10.3.3 Assignment calls setters, definition doesn’t

What is the difference between defining the property .prop of obj versus assigning to it?

If we define, then our intention is to either create or change an own (non-inherited) property of obj. Therefore, definition ignores the inherited setter for .prop in the following example:

let setterWasCalled = false;
const proto = {
  get prop() {
    return 'protoGetter';
  },
  set prop(x) {
    setterWasCalled = true;
  },
};
const obj = Object.create(proto);

assert.equal(obj.prop, 'protoGetter');

// Defining obj.prop:
Object.defineProperty(
  obj, 'prop', { value: 'objData' });
assert.equal(setterWasCalled, false);

// We have overridden the getter:
assert.equal(obj.prop, 'objData');

If, instead, we assign to .prop, then our intention is often to change something that already exists and that change should be handled by the setter:

let setterWasCalled = false;
const proto = {
  get prop() {
    return 'protoGetter';
  },
  set prop(x) {
    setterWasCalled = true;
  },
};
const obj = Object.create(proto);

assert.equal(obj.prop, 'protoGetter');

// Assigning to obj.prop:
obj.prop = 'objData';
assert.equal(setterWasCalled, true);

// The getter still active:
assert.equal(obj.prop, 'protoGetter');

10.3.4 Inherited read-only properties prevent creating own properties via assignment

What happens if .prop is read-only in a prototype?

const proto = Object.defineProperty(
  {}, 'prop', {
    value: 'protoValue',
    writable: false,
  });

In any object that inherits the read-only .prop from proto, we can’t use assignment to create an own property with the same key – for example:

const obj = Object.create(proto);
assert.throws(
  () => obj.prop = 'objValue',
  /^TypeError: Cannot assign to read only property 'prop'/);

Why can’t we assign? The rationale is that overriding an inherited property by creating an own property can be seen as non-destructively changing the inherited property. Arguably, if a property is non-writable, we shouldn’t be able to do that.

However, defining .prop still works and lets us override:

Object.defineProperty(
  obj, 'prop', { value: 'objValue' });
assert.equal(obj.prop, 'objValue');

Accessor properties that don’t have a setter are also considered to be read-only:

const proto = {
  get prop() {
    return 'protoValue';
  }
};
const obj = Object.create(proto);
assert.throws(
  () => obj.prop = 'objValue',
  /^TypeError: Cannot set property prop of #<Object> which has only a getter$/);

  The “override mistake”: pros and cons

The fact that read-only properties prevent assignment earlier in the prototype chain, has been given the name override mistake:

10.4 Which language constructs use definition, which assignment?

In this section, we examine where the language uses definition and where it uses assignment. We detect which operation is used by tracking whether or not inherited setters are called. See §10.3.3 “Assignment calls setters, definition doesn’t” for more information.

10.4.1 The properties of an object literal are added via definition

When we create properties via an object literal, JavaScript always uses definition (and therefore never calls inherited setters):

let lastSetterArgument;
const proto = {
  set prop(x) {
    lastSetterArgument = x;
  },
};
const obj = {
  __proto__: proto,
  prop: 'abc',
};
assert.equal(lastSetterArgument, undefined);

10.4.2 The assignment operator = always uses assignment

The assignment operator = always uses assignment to create or change properties.

let lastSetterArgument;
const proto = {
  set prop(x) {
    lastSetterArgument = x;
  },
};
const obj = Object.create(proto);

// Normal assignment:
obj.prop = 'abc';
assert.equal(lastSetterArgument, 'abc');

// Assigning via destructuring:
[obj.prop] = ['def'];
assert.equal(lastSetterArgument, 'def');

10.4.3 Public class fields are added via definition

Alas, even though public class fields have the same syntax as assignment, they do not use assignment to create properties, they use definition (like properties in object literals):

let lastSetterArgument1;
let lastSetterArgument2;
class A {
  set prop1(x) {
    lastSetterArgument1 = x;
  }
  set prop2(x) {
    lastSetterArgument2 = x;
  }
}
class B extends A {
  prop1 = 'one';
  constructor() {
    super();
    this.prop2 = 'two';
  }
}
new B();

// The public class field uses definition:
assert.equal(lastSetterArgument1, undefined);
// Inside the constructor, we trigger assignment:
assert.equal(lastSetterArgument2, 'two');

10.5 Further reading and sources of this chapter