How do primitive values get their properties?

[2022-03-02] dev, javascript, jslang
(Ad, please don’t block)

This blog post is second in a series of two:

  1. What are wrapper objects for primitive values?
  2. How do primitive values get their properties?

JavaScript has two kinds of values:

  • primitive values (undefined, null, booleans, numbers, bigints, strings, symbols)
  • objects (all other values)

The ECMAScript specification states:

A primitive value is a datum that is represented directly at the lowest level of the language implementation.

However, despite this fact, we can still use primitive values (other than undefined and null) as if they were immutable objects:

> 'xy'.length
2

This blog post answers the following question:

How do primitive values get their properties?

We’ll look at:

  • Getting property values
  • Invoking property values (a.k.a. method calls)
  • Setting property values

Each time, we’ll first examine what’s going on via JavaScript code and then investigate how the language specification explains the phenomena.

Note that JavaScript engines only mimick the external behavior of the language specification. Some of what the spec does internally is not very efficient (e.g. wrapping primitive values) and often done differently in engines.

Required knowledge  

In order to understand this blog post, you should be loosely familiar with the following two topics.

  • The primitive types boolean, number, bigint, string, and symbol have the associated wrapper classes Boolean, Number, BigInt, and Symbol. The previous blog post has more information on them.

  • JavaScript has two different modes for executing code:

    • The cleaner strict mode:
      • Is switched on implicitly in modules and classes.
      • Must be switched on explicitly elsewhere.
    • The older non-strict mode (a.k.a. sloppy mode).

    For more information on these modes, see JavaScript for impatient programmers.

References: referring to storage locations in the specification  

When performing operations such as getting, setting or invoking, the affected storage locations could be:

  • Property references (value.someProperty, value[someKey])
  • Super references (super.someProperty, super[someKey])
  • Private references (value.#someSlot)
  • Variable references (someVariable)

The specification uses a single data structure, reference records (short: references) to represent all of these storage locations. That makes it easier to describe the operations.

A reference record is an intermediate spec-internal value that represents storage locations. It has the following slots:

  • [[Base]] (language value, environment record, unresolvable): the container in which a value is stored

  • [[ReferencedName]] (string, symbol, private name): the key of a binding. If the base is an environment record, it’s always a string.

  • [[Strict]] (boolean): true if the reference was created in strict mode, false otherwise.

  • [[ThisValue]] (language value, empty): only needed for super references (which need to preserve the value of this). In other references, it is the spec-internal value empty.

Referring to a property in JavaScript code initially produces a reference that stores both the base and the property key. The following two spec operations illustrate how references work:

  • GetValue(V) converts an intermediate value V (a spec-internal value or a language value) to a language value. This is done before storing a (possibly intermediate) value somewhere, before using a value as an argument in a function call, etc. If V is a reference, it is “dereferenced” and becomes a language value. In the process, the reference and its information is lost. We don’t know where the language value came from anymore.

  • PutValue(V, W) stores a value W in a storage location V which must be a reference. For this operation, a reference provides crucial information: In which base (i.e., container) should we store W? Where in the container should we store it?

In the next section, we’ll explore how referring to the property of a primitive value produces a reference. In subsequent sections, we’ll see what happens when we are getting, invoking or assigning to references.

Referring to the property of a primitive produces a property reference  

Consider the following syntax that refers to a property via the dot (.) operator (relevant section in the spec):

MemberExpression . IdentifierName

If MemberExpression evaluates to a primitive value, evaluation produces the following property reference record:

  • [[Base]]: the primitive value that MemberExpression evaluates to
  • [[ReferencedName]]: the name of the property (a string)
  • [[Strict]]: a boolean indicating whether the property was mentioned in strict mode or not
  • [[ThisValue]]: is empty

Getting references  

Getting a reference is something that happens before an argument can be used by an invoked function and before a value can be stored somewhere.

JavaScript: getting  

When we access an own (non-inherited) property of a primitive value, JavaScript converts the primitive value to an object by wrapping it. It then returns the value of the corresponding own property of the wrapper object:

> 'xy'.length === new String('xy').length
true

The previous interaction is relatively weak evidence that primitives get properties via wrapping. Inherited primitive properties also come from wrapper objects. And here the evidence is stronger:

> 'xy'.startsWith === new String('xy').startsWith
true
> 'xy'.startsWith === String.prototype.startsWith
true

We’ll get definitive proof for the role of wrapping next, by looking at the language specification.

Language specification: getting  

The spec operation GetValue(V) is the final step when evaluating an expression (syntax) to an actual JavaScript value. In converts a spec-internal value or a language value to a language value.

If GetValue() is applied to a reference V that was created by accessing a property of a primitive value (see previous section), the following steps happen:

  • Let baseObj be ToObject(V.[[Base]]). This is an important step – see below why.
  • The property value is read from baseObj via a spec-internal method:
    baseObj.[[Get]](V.[[ReferencedName]], GetThisValue(V))
    
    The second parameter is only needed for getters (which have access to the dynamic receiver of a property access).
  • The most commonly used implementation of the internal method .[[Get]]() is the one of ordinary objects.
    • It calls the spec operation OrdinaryGet(O, P, Receiver) which does the following:
      • Traverse the prototype chain of O until you find a property P. If you can’t find a property, return undefined.
      • If P is a data property, return its value. If P is an accessor with a getter, invoke it. Otherwise, return undefined.

The crucial step is this one:

Let baseObj be to ToObject(V.[[Base]])

ToObject() ensures that baseObj is an object, so that JavaScript can access the property whose name is V.[[ReferencedName]]. In our case, V.[[Base]] is primitive and ToObject() wraps it.

This proves that getting the value of a property of a primitive is achieved by wrapping the primitive.

Invoking references  

JavaScript: invoking  

We have already seen that all properties of primitive values come from wrapper objects – especially inherited properties (which most methods are):

> 'xy'.startsWith === String.prototype.startsWith
true

Let’s investigate what the value of this is when we invoke a method on a primitive value. To get access to this, we add two methods to Object.prototype which are inherited by all wrapper classes:

Object.prototype.getThisStrict = new Function(
  "'use strict'; return this"
);
Object.prototype.getThisSloppy = new Function(
  "return this"
);

We create the methods via new Function() because it always executes its “body” in non-strict mode.

Let’s invoke the methods on a string:

assert.deepEqual(
  'xy'.getThisStrict(), 'xy'
);
assert.deepEqual(
  'xy'.getThisSloppy(), new String('xy')
);

In strict mode, this refers to the (unwrapped) string. In sloppy mode, this refers to a wrapped version of the string.

Language specification: invoking  

In the previous subsection, we have seen that when we invoke a method on a primitive value p, there are two important phenomena:

  1. The method is looked up in a wrapped version of p.
  2. In strict mode, this refers to p. In sloppy mode, this refers to a wrapped version of p.

In the spec, a method call is made by invoking (second CallExpression rule) a property reference (first CallExpression rule). These are the relevant syntax rules:

  • CallExpression :
    • CallExpression . IdentifierName
    • CallExpression Arguments
    • (Remaining rules are omitted)
  • Arguments :
    • ( )
    • ( ArgumentList )
    • ( ArgumentList , )

The evaluation of the second CallExpression rule is handled as follows.

CallExpression Arguments

  • Let ref be the result of evaluating CallExpression, a property reference.
  • Let func be the result of GetValue(ref).
    • Phenomenon 1: We have seen in the previous section that GetValue() wraps the base of a property reference so that it can read a property value.
  • Next, a spec operation is called: EvaluateCall(func, ref, Arguments, tailCall) (we are ignoring tailCall, as it’s not relevant to this blog post).

EvaluateCall(func, ref, arguments, tailPosition) works as follows (I’m omitting a few steps that are not relevant here):

  • Determine thisValue:
    • Is ref a property reference? Then thisValue is GetThisValue(ref).
      • GetThisValue(ref) returns ref.[[Base]] (if ref is not a super reference).
        • Phenomenon 2: thisValue is still primitive at this point.
    • Otherwise, thisValue is undefined.
  • The arguments are evaluated and the result is assigned to argList.
  • If func is not an object or not callable, a TypeError is thrown.
  • A spec operation is invoked: Call(func, thisValue, argList)
    • This operation returns func.[[Call]](thisValue, argList).

If func is an ordinary function, its implementation .[[Call]](thisArgument, argumentsList) is invoked:

  • calleeContext is a new execution context (with storage for parameters and more) that is created for the current call.
  • F = this
  • A spec operation is called – OrdinaryCallBindThis(F, calleeContext, thisArgument):
    • The operation checks F.[[ThisMode]]:
      • lexical: don’t create a this binding (F is an arrow function)
      • strict: this is bound to thisArgument. Therefore:
        • this is undefined if F didn’t come from a property reference.
        • this is primitive if F came from a primitive property reference.
          • Phenomenon 2: this is not wrapped in strict mode.
      • Otherwise we are in sloppy mode:
        • If thisArgument is null or undefined:
          • Let calleeRealm be F.[[Realm]]. A realm is one “instance” of the JavaScript platform (global data etc.). In web browsers, each iframe has its own realm.
          • Let this be calleeRealm.[[GlobalEnv]].[[GlobalThisValue]]
        • Otherwise, this is bound to ToObject(thisArgument).
          • Phenomenon 2: this is wrapped in sloppy mode.

Assigning to references  

JavaScript: changing existing properties  

In strict mode, we get an exception if we try to change an existing property of a primitive value (in sloppy mode, there is a silent failure):

assert.throws(
  () => 'xy'.length = 1,
  {
    name: 'TypeError',
    message: "Cannot assign to read only property 'length' of string 'xy'"
  }
);

Why doesn’t that work? The primitive value is wrapped before the assignment happens and all own properties of a wrapped string are non-writable (immutable):

assert.deepEqual(
  Object.getOwnPropertyDescriptors('xy'),
  {
    '0': {
      value: 'x',
      writable: false,
      enumerable: true,
      configurable: false
    },
    '1': {
      value: 'y',
      writable: false,
      enumerable: true,
      configurable: false
    },
    length: {
      value: 2,
      writable: false,
      enumerable: false,
      configurable: false
    }
  }
);

For information on property descriptors, see “Deep JavaScript”.

JavaScript: creating new properties  

We also can’t create new properties for primitive values. That fails with an exception in strict mode (silently in sloppy mode):

assert.throws(
  () => 'xy'.newProp = true,
  {
    name: 'TypeError',
    message: "Cannot create property 'newProp' on string 'xy'"
  }
);

Why that doesn’t work is more complicated this time. As it turns out, we can add properties to wrapped primitive values:

const wrapped = new String('xy');
wrapped.newProp = true;
assert.equal(
  wrapped.newProp, true
);

So, wrapper objects are clearly extensible (new properties can be added):

assert.equal(
  Object.isExtensible(new String('xy')), true
);

However, primitive values are not extensible:

assert.equal(
  Object.isExtensible('xy'), false
);

Language Specification: assigning to references  

In this subsection, we explore two phenomena:

  1. Primitive values are wrapped before assignment happens. That’s why existing (own and inherited) properties can’t be changed.
  2. We can’t add new properties to primitive values.

This is the syntax for assigning:

LeftHandSideExpression = AssignmentExpression

Its evaluation consists of the following steps:

  • Let lref be the result of evaluating LeftHandSideExpression.
  • Let rref be the result of evaluating AssignmentExpression.
  • Let rval be GetValue(rref).
  • Perform PutValue(lref, rval).
  • Return rval.

The spec operation PutValue(V, W) performs the actual assignment:

  • Let baseObj be ToObject(V.[[Base]]).
    • Phenomenon 1: The primitive value in V.[[Base]] is wrapped before setting.
  • Let succeeded be baseObj.[[Set]](V.[[ReferencedName]], W, GetThisValue(V)).
    • Note that the last argument is a primitive value. It is used when invoking a setter, which has access to this (as with methods, unwrapped in strict mode, wrapped in sloppy mode).
  • If succeeded is false and V.[[Strict]] is true, throw a TypeError exception. That is, in strict mode, there is an exception, in sloppy mode a silent failure.

The most commonly used implementation of the internal method .[[Set]]() is the one of ordinary objects:

  • Return OrdinarySet(O, P, V, Receiver).
    • Let ownDesc be O.[[GetOwnProperty]](P).
      • ownDesc describes property P prior to assigning. It determines if an assignment can be made – e.g.: If P isn’t writable, we can’t assign to it.
    • Return OrdinarySetWithOwnDescriptor(O, P, V, Receiver, ownDesc).
      • If the own property descriptor ownDesc is undefined (which means there is no own property whose name is P): Try to find a setter by traversing the prototype chain of O. Note that Receiver always points to where setting started.
        • If that isn’t successful, assign a default data property descriptor to ownDesc:
          {
            [[Value]]: undefined,
            [[Writable]]: true,
            [[Enumerable]]: true,
            [[Configurable]]: true,
          }
          
      • Is ownDesc a data descriptor?
        • If ownDesc isn’t writable, return false.
          • Phenomenon 1: If an inherited or own property is read-only, we can’t assign to it.
        • If Type(Receiver) is not Object, return false.
          • Phenomenon 2: We can’t add new properties to primitive values.
      • (The remaining steps are omitted.)

Language specification: Why are primitive values not extensible?  

In this subsection we explore why primitive values are not extensible.

Object.isExtensible(O) is specified as follows:

  • If Type(O) is not Object, return false.
    • This explains the result for primitive values.
  • Return IsExtensible(O).

Further reading