This blog post is second in a series of two:
JavaScript has two kinds of values:
undefined
, null
, booleans, numbers, bigints, strings, symbols)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:
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.
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:
For more information on these modes, see JavaScript for impatient programmers.
When performing operations such as getting, setting or invoking, the affected storage locations could be:
value.someProperty
, value[someKey]
)super.someProperty
, super[someKey]
)value.#someSlot
)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.
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 a reference is something that happens before an argument can be used by an invoked function and before a value can be stored somewhere.
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.
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:
baseObj
be ToObject(V.[[Base]])
. This is an important step – see below why.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)..[[Get]]()
is the one of ordinary objects.
OrdinaryGet(O, P, Receiver)
which does the following:
O
until you find a property P
. If you can’t find a property, return undefined
.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 toToObject(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.
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.
In the previous subsection, we have seen that when we invoke a method on a primitive value p
, there are two important phenomena:
p
.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:
.
IdentifierName(
)
(
ArgumentList )
(
ArgumentList , )
The evaluation of the second CallExpression rule is handled as follows.
CallExpression Arguments
ref
be the result of evaluating CallExpression
, a property reference.func
be the result of GetValue(ref)
.
GetValue()
wraps the base of a property reference so that it can read a property value.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):
thisValue
:
ref
a property reference? Then thisValue
is GetThisValue(ref)
.
GetThisValue(ref)
returns ref.[[Base]]
(if ref
is not a super
reference).
thisValue
is still primitive at this point.thisValue
is undefined
.arguments
are evaluated and the result is assigned to argList
.func
is not an object or not callable, a TypeError
is thrown.Call(func, thisValue, argList)
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
OrdinaryCallBindThis(F, calleeContext, thisArgument)
:
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.
this
is not wrapped in strict mode.thisArgument
is null
or undefined
:
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.this
be calleeRealm.[[GlobalEnv]].[[GlobalThisValue]]
this
is bound to ToObject(thisArgument)
.
this
is wrapped in sloppy mode.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”.
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
);
In this subsection, we explore two phenomena:
This is the syntax for assigning:
LeftHandSideExpression
=
AssignmentExpression
Its evaluation consists of the following steps:
lref
be the result of evaluating LeftHandSideExpression
.rref
be the result of evaluating AssignmentExpression
.rval
be GetValue(rref)
.PutValue(lref, rval)
.rval
.The spec operation PutValue(V, W)
performs the actual assignment:
baseObj
be ToObject(V.[[Base]])
.
V.[[Base]]
is wrapped before setting.succeeded
be baseObj.[[Set]](V.[[ReferencedName]], W, GetThisValue(V))
.
this
(as with methods, unwrapped in strict mode, wrapped in sloppy mode).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:
OrdinarySet(O, P, V, Receiver)
.
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.OrdinarySetWithOwnDescriptor(O, P, V, Receiver, ownDesc)
.
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.
ownDesc
:{
[[Value]]: undefined,
[[Writable]]: true,
[[Enumerable]]: true,
[[Configurable]]: true,
}
ownDesc
a data descriptor?
ownDesc
isn’t writable, return false
.
Type(Receiver)
is not Object, return false
.
In this subsection we explore why primitive values are not extensible.
Object.isExtensible(O)
is specified as follows:
Type(O)
is not Object, return false
.
IsExtensible(O)
.
O.[[IsExtensible]]()
.
.[[IsExtensible]]()
of ordinary objects:OrdinaryIsExtensible(O)
:
O.[[Extensible]]
(a boolean)Blog post on references in JavaScript: “Why is (0,obj.prop)()
not a method call?”
Section “Strict mode vs. sloppy mode” in “JavaScript for impatient programmers”
Chapter “Property attributes: an introduction” in “Deep JavaScript” [attributes of properties, encoding them via property descriptors]
Chapter “Protecting objects from being changed” in “Deep JavaScript” [levels of protecting an object: preventing extensions, sealing, freezing]