JS Internals
Chapter 04SpecEngine4 runnable probes

Prototypes, Function Objects, and Class Semantics

Delegation, [[Call]] versus [[Construct]], and the parts of `class` that are not syntactic sugar.

JavaScript's object model is a single mechanism, delegation through [[Prototype]], dressed in several syntaxes. This chapter treats the mechanism precisely: how [[Get]] and [[Set]] walk the chain with a separate receiver, what a function object actually contains, how `new` and `super` are defined, and where class syntax introduces semantics (define versus set, private names, brand checks, derived-constructor `this` TDZ) that cannot be expressed in ES5.

In this chapter
  1. [[Get]] and [[Set]] carry a receiver, and that is the whole story
  2. What a function object contains
  3. [[Construct]], new.target, and prototype resolution
  4. `this` is a binding in an Environment Record, not a property of the call
  5. Where `class` is not sugar
  6. @@species: the built-in methods that construct their own return value

[[Get]] and [[Set]] carry a receiver, and that is the whole story

OrdinaryGet(O, P, Receiver): look up the own property P on O. If absent, recurse on O.[[GetPrototypeOf]]() with the same Receiver. If found and it is a data property, return its value. If it is an accessor, call the getter with this = Receiver. The receiver stays fixed while O walks up the chain, which is why a getter defined on a prototype sees the instance as this.

OrdinarySet is asymmetric and less well known. It also walks the chain, but what it does when it finds the property depends on what it finds. If the chain yields a data property, OrdinarySetWithOwnDescriptor checks [[Writable]]; if writable, it creates or updates an own property on the Receiver, not on the holder. If not writable, the assignment fails (silently in sloppy mode, TypeError in strict). If the chain yields an accessor, the setter is called with this = Receiver; if there is no setter, the assignment fails. Only if nothing is found anywhere is a fresh own data property created on the Receiver. Two consequences: a non-writable property on a prototype blocks assignment on all descendants, and a prototype getter without a setter makes the property read-only for every instance.

ProbeOrdinarySet: what the prototype chain does to an assignment
"use strict";
const proto = {};
Object.defineProperty(proto, "frozenOnProto", { value: 1, writable: false, configurable: true });
Object.defineProperty(proto, "getterOnly", { get() { return "from proto"; }, configurable: true });
Object.defineProperty(proto, "accessor", {
  get() { return this._v; },
  set(v) { console.log("setter called with this ===", this === child ? "child (Receiver)" : "proto"); this._v = v; },
});

const child = Object.create(proto);
child.plain = 1;                       // nothing on chain -> own data property
console.log("own keys after plain:", Object.keys(child));

try { child.frozenOnProto = 2; } catch (e) { console.log("non-writable on proto blocks child:", e.constructor.name); }
try { child.getterOnly = 2; } catch (e) { console.log("getter-only on proto blocks child:", e.constructor.name); }

child.accessor = 42;                   // setter invoked with Receiver = child
console.log("own keys now:", Object.keys(child), "| child.accessor =", child.accessor);

// Object.defineProperty bypasses the chain entirely: it is [[DefineOwnProperty]], not [[Set]].
Object.defineProperty(child, "frozenOnProto", { value: "shadowed", writable: true, configurable: true });
console.log("after defineProperty:", child.frozenOnProto);

Object.prototype.__proto__ is an accessor property (specified in Annex B) whose getter returns [[GetPrototypeOf]]() and whose setter calls [[SetPrototypeOf]]. Because it is an ordinary accessor found through the chain, Object.create(null) objects do not have it, and an own data property named __proto__ (creatable via Object.defineProperty or JSON.parse) shadows it. This is the root of prototype-pollution vulnerabilities: code that copies attacker-controlled keys with target[key] = value will hit the setter when key === "__proto__" and reassign the prototype of target, or, when recursively merging, write into Object.prototype itself.

What a function object contains

An ECMAScript function object is an ordinary object with [[Call]] and a set of internal slots that determine how the call behaves. The relevant ones:

Internal slotSet byEffect
[[Environment]]The Environment Record in scope at creationThe closure. Every call creates a new Function Environment Record whose [[OuterEnv]] is this slot.
[[FormalParameters]], [[ECMAScriptCode]]The parseExecuted by FunctionDeclarationInstantiation and then the body.
[[ThisMode]]lexical for arrows, strict for strict functions, global otherwiseLexical: no this binding at all (resolve outward). Strict: this is the value passed. Global: undefined/null become the global object, primitives are boxed with ToObject.
[[ConstructorKind]]base or derived (class with extends)Derived constructors do not create this on entry; super() does.
[[HomeObject]]The object literal or class whose method this issuper.x is [[HomeObject]].[[GetPrototypeOf]]().[[Get]]("x", this). Only methods have it; ordinary function expressions do not, hence super is a SyntaxError there.
[[Fields]], [[PrivateMethods]], [[ClassFieldInitializerName]]Class evaluationRun by InitializeInstanceElements immediately after this is created (base) or after super() returns (derived).
[[IsClassConstructor]]class syntaxCalling without new throws TypeError before evaluating anything.
[[SourceText]]The parseReturned verbatim by Function.prototype.toString; the spec requires the exact source slice, including comments.

Not every function has [[Construct]]. Arrow functions, methods defined with concise syntax, generator functions, async functions, and class methods (other than the constructor) are not constructors: new (() => {}) throws. Only ordinary function declarations/expressions and class constructors have [[Construct]]. The same rule decides whether a function gets a prototype property at creation; arrows and methods do not.

[[Construct]], new.target, and prototype resolution

new F(args) evaluates to ? Construct(F, args, F), i.e. F.[[Construct]](args, newTarget = F). For an ordinary base constructor, [[Construct]] performs OrdinaryCreateFromConstructor(newTarget, "%Object.prototype%"): it reads newTarget.prototype via [[Get]]; if the result is an Object, that becomes the new object's [[Prototype]]; otherwise it falls back to the intrinsic %Object.prototype% of the realm of newTarget. Then it binds this, runs field initialisers, and executes the body. If the body returns an Object, that object is the result and the freshly created one is discarded; any other return value is ignored.

Separating F (the function being called) from newTarget (the function new was applied to) is what makes subclassing built-ins possible. When class Stack extends Array {} is instantiated, Stack's derived constructor calls super(...), which is Construct(Array, args, newTarget = Stack). Array's [[Construct]] therefore creates an Array exotic object whose [[Prototype]] is Stack.prototype. Without newTarget, built-ins could not be subclassed without a second allocation and a prototype swap. Reflect.construct(F, args, newTarget) exposes the third argument directly.

ProbeReflect.construct decouples the constructor from the prototype source
function Base() { console.log("new.target is", new.target?.name ?? String(new.target)); this.base = true; }
Base.prototype.hello = () => "hello from Base.prototype";

function Other() {}
Other.prototype.hello = () => "hello from Other.prototype";

const a = new Base();
const b = Reflect.construct(Base, [], Other);          // body from Base, [[Prototype]] from Other.prototype
console.log(a.hello(), "|", b.hello(), "| b instanceof Base:", b instanceof Base);

// Exotic-object creation follows newTarget too: an Array exotic object with a custom prototype.
class Stack extends Array { peek() { return this[this.length - 1]; } }
const s = Stack.from([1, 2, 3]);
console.log("Array.isArray(s):", Array.isArray(s), "| peek:", s.peek(), "| map keeps class via @@species:", s.map((x) => x * 2) instanceof Stack);

// Calling a constructor without new: new.target is undefined
Base.call({});

`this` is a binding in an Environment Record, not a property of the call

Every Function Environment Record has [[ThisValue]] and [[ThisBindingStatus]] (lexical, initialized, or uninitialized). When a function is called, OrdinaryCallBindThis computes the this value from the function's [[ThisMode]] and the call's receiver and initialises the binding. Arrow functions have status lexical: the this keyword is resolved by ResolveThisBinding, which walks [[OuterEnv]] until it finds an environment that has a this binding. Derived class constructors start with status uninitialized; super() initialises it; touching this before that throws a ReferenceError (this is a TDZ, structurally identical to let before initialisation); and calling super() twice throws because BindThisValue rejects an already-initialised binding.

The receiver of a call comes from the Reference Record produced by evaluating the callee expression. o.m() evaluates o.m to a Reference with [[Base]] = o, and EvaluateCall passes GetThisValue(ref) as this. Any expression that yields a value rather than a Reference ((0, o.m)(), (o.m)() is still a Reference because parentheses preserve it, o.m.bind(x)(), [o.m][0]()) loses the base and the callee runs with this = undefined. This is not a quirk of method extraction; it is the grammar telling you exactly which expressions produce References.

Where `class` is not sugar

Fields use [[DefineOwnProperty]], not [[Set]]

A public instance field x = 1; is evaluated by DefineField, which calls CreateDataPropertyOrThrow, i.e. [[DefineOwnProperty]]. It does not perform this.x = 1. Therefore a setter on the prototype chain named x is not invoked, and a non-writable inherited x does not block it. This was a deliberate and contested TC39 decision ("define semantics"): it makes fields predictable regardless of what the superclass does, at the cost of silently shadowing accessors. Migrating code from this.x = v in a constructor to a field declaration is therefore a behavioural change whenever a superclass defines an accessor with that name.

ProbeDefine versus Set semantics
class Base {
  set value(v) { console.log("Base setter ran with", v); this._value = v; }
  get value() { return this._value; }
}
class ViaAssignment extends Base { constructor() { super(); this.value = 1; } }   // [[Set]] -> setter
class ViaField extends Base { value = 2; }                                         // [[DefineOwnProperty]] -> shadows

const a = new ViaAssignment();
const b = new ViaField();
console.log("assignment: own keys", Object.keys(a), "| value =", a.value);
console.log("field:      own keys", Object.keys(b), "| value =", b.value, "| descriptor:", Object.getOwnPropertyDescriptor(b, "value"));

Private names are keys in a separate namespace, bound per class evaluation

#x is not a string property with a mangled name. Each evaluation of a class declaration creates fresh Private Name values for each #identifier in the body, stores them in a Private Environment Record scoped to the class body, and instances store private elements in an internal [[PrivateElements]] list keyed by those Private Names. Lookups (PrivateGet, PrivateSet) are exact-identity matches with no prototype walk and no Proxy interception; a miss throws TypeError rather than returning undefined. Because the names are per-evaluation, two classes produced by running the same class expression twice have mutually inaccessible #x fields. The #x in obj brand check (ES2022) tests membership without throwing, making private names a robust replacement for instanceof when identity of the class evaluation, not the prototype chain, is what you mean.

ProbePrivate names are per class evaluation and immune to prototype tricks
const makeCounter = () => class Counter {
  #n = 0;
  inc() { return ++this.#n; }
  static is(o) { return #n in o; }              // brand check: no throw, no prototype walk
  static peek(o) { return o.#n; }                // throws TypeError for foreign objects
};
const A = makeCounter(), B = makeCounter();
const a = new A();
console.log("A.is(a):", A.is(a), "| B.is(a):", B.is(a), "(same source, different Private Names)");

const fake = Object.create(A.prototype);         // right prototype, no private element
console.log("fake instanceof A:", fake instanceof A, "| A.is(fake):", A.is(fake));
try { A.peek(fake); } catch (e) { console.log("A.peek(fake):", e.constructor.name, "-", e.message); }

// Proxies cannot see or forward private elements.
const p = new Proxy(a, { get(t, k) { console.log("trap saw", String(k)); return Reflect.get(t, k); } });
try { p.inc(); } catch (e) { console.log("p.inc():", e.constructor.name, "- the method ran with this = proxy, which has no #n"); }

Class evaluation order and the heritage expression

ClassDefinitionEvaluation proceeds in a fixed order: the class binding is created uninitialised (so the class name is in TDZ inside its own extends clause); the extends expression is evaluated; its result must be null or a constructor with an Object-or-null prototype; F.prototype is created with the resolved parent prototype; methods and accessors are defined (computed keys evaluated in source order); static fields and static {} blocks are executed in source order after all methods exist; and finally the class binding is initialised. Instance fields are not evaluated at this time at all; they are stored and run per construction. Consequences: a static block can call static methods defined above or below it; a computed method key can reference outer variables but not the class itself; and class A extends A {} throws ReferenceError, not a cyclic-prototype error.

@@species: the built-in methods that construct their own return value

Array.prototype.map, filter, slice, splice, concat, and flat do not simply create an Array; they call ArraySpeciesCreate(this, length), which reads this.constructor, then constructor[@@species], and constructs that. Promise.prototype.then uses SpeciesConstructor the same way, as do TypedArray and RegExp methods. This is why Stack.from([1,2,3]).map(f) returned a Stack above. It is also a known source of both performance cost (every map call performs two property lookups and a constructor call instead of a direct allocation) and security surface (an attacker who can define constructor on an array can make map allocate arbitrary objects). TC39 has discussed removing @@species; the newer methods toSorted, toReversed, toSpliced, and with (ES2023) deliberately ignore it and always return plain Arrays.

Origin

Where these ideas come from

The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.

MechanismOriginPath into JavaScript
Prototype-based object modelHenry Lieberman; David Ungar & Randall Smith, Using Prototypical Objects to Implement Shared Behavior (1986); Self: The Power of Simplicity (1987) (1987)Delegation through a parent slot instead of classes; JavaScript's [[Prototype]] is Self's parent, adopted by Eich in 1995.
Closures and first-class functionsPeter Landin, The Mechanical Evaluation of Expressions (1964)Functions as values with captured environments; JavaScript's [[Environment]] slot is Landin's closure.
Classes as sugar over prototypesAllen Wirfs-Brock, Dave Herman, et al. (TC39), ECMA-262 6th edition, ClassDefinitionEvaluation (2015)The 'maximally minimal classes' design deliberately produced ordinary constructor functions and prototypes, then grew fields and private names.
Private state via unforgeable keysDaniel Ehrenberg, Jeff Morrison, et al. (TC39), Class fields and private methods proposals (ES2022) (2022)Private Names as per-evaluation unique keys, with define semantics for fields; the brand check #x in obj followed in ES2022.

Primary sources