Under the Hood of class: ClassDefinitionEvaluation and Private Fields
class is syntactic sugar, but not simple syntactic sugar. ClassDefinitionEvaluation does far more than function + prototype handwriting can replicate when it comes to setting up the prototype chain, registering methods, and handling private fields. Private fields in particular operate entirely outside the prototype chain.
๐น Level 1 ยท What You Need to Know
The Basic Structure of a Class
class Animal {
#name // private field declaration (must appear at the top of the class body)
constructor(name) {
this.#name = name // private field assignment
}
speak() { // instance method (defined on the prototype)
return `${this.#name} makes a sound`
}
static create(name) { // static method (defined on the class itself)
return new Animal(name)
}
get name() { // getter (defined on the prototype)
return this.#name
}
}
class Dog extends Animal {
constructor(name) {
super(name) // must be called before using 'this'!
this.type = 'dog'
}
speak() {
return `${this.name} barks` // access private field via getter
}
}
const d = new Dog('Rex')
console.log(d.speak()) // 'Rex barks'
console.log(d instanceof Dog) // true
console.log(d instanceof Animal) // true
Where Methods Are Defined
| Syntax | Defined On | Enumerable |
|---|---|---|
method() {} |
ClassName.prototype |
false |
static method() {} |
ClassName |
false |
get prop() {} |
ClassName.prototype |
false |
#privateMethod() {} |
Each instance object | โ |
Core Characteristics of Private Fields
#field is truly private, not a convention like _field:
class Secret {
#value = 42
reveal() { return this.#value }
}
const s = new Secret()
console.log(s.reveal()) // 42
console.log(s.#value) // SyntaxError: Private field '#value' must be declared in an enclosing class
console.log(s['#value']) // undefined (this is an ordinary property, not a private field)
console.log('#value' in s) // false (regular 'in' cannot see private fields)
Why super() Must Come Before this
A subclass's this is created and initialized by super(). Using this before calling super() throws a ReferenceError:
class Parent {
constructor() { this.x = 1 }
}
class Child extends Parent {
constructor() {
// console.log(this) // ReferenceError: Must call super constructor before accessing 'this'
super()
console.log(this) // Parent { x: 1 } โ this has been initialized by super()
this.y = 2
}
}
๐ธ Level 2 ยท How It Actually Works
The Complete Prototype Structure of class Inheritance
class Animal { ... }
class Dog extends Animal { ... }
Complete prototype chain structure:
Dog (function object) Animal (function object)
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ
โ [[Prototype]] โโโโโโโโโโโโโโโโโโบโ (Animal fn) โ
โ โ โ โ
โ prototype โโโโโโโโโโ โ prototype โโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโ โ โโโโโโโโโโโโโโโโโโโ โ
โ โ
โผ โผ
Dog.prototype โ Animal.prototype โ
โโโโโโโโโโโโโโโโโโโ โ โโโโโโโโโโโโโโโโโโโ โ
โ [[Prototype]] โโโโโโโโโโโโโโโโโโบโ [[Prototype]] โโโโโโโโโบ Object.prototype
โ constructor โโโโโโโโโโโบ Dog โ constructor โโโโโโโโโโโบ Animal
โ speak() โ โ โ speak() โ โ
โโโโโโโโโโโโโโโโโโโ โ โโโโโโโโโโโโโโโโโโโ โ
โฒ โ โฒ โ
โ โ โ โ
new Dog() instance โ new Animal() instanceโ
โโโโโโโโโโโโโโโโโโโ โ โโโโโโโโโโโโโโโโโโโ โ
โ [[Prototype]] โโโโโโ โ [[Prototype]] โโโโโโ
โ type: 'dog' โ โ (own properties) โ
โ #name (private) โ โ #name (private) โ
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ
Two prototype chains:
1. Instance chain: instance โ Dog.prototype โ Animal.prototype โ Object.prototype
2. Constructor chain: Dog โ Animal โ Function.prototype
(this allows static methods to be inherited)
ClassDefinitionEvaluation Step by Step
Spec 15.7.14 ClassDefinitionEvaluation is the core algorithm invoked when a class declaration or expression is evaluated. Simplified steps:
ClassDefinitionEvaluation(classBinding, classHeritage, classBody):
Step 1: Create class environment
- Create ClassEnvironment (scope for private names)
- If classBinding present, bind class name in environment
(the name of a class expression is visible inside the body)
Step 2: Handle extends (if present)
- Evaluate classHeritage โ superclass
- Verify superclass is a function or null
- Determine protoParent (= superclass.prototype or null)
- Determine constructorParent (= superclass)
Step 3: Create the prototype object
- proto = OrdinaryObjectCreate(protoParent)
i.e., proto.[[Prototype]] = protoParent (superclass's prototype)
Step 4: Initialize the constructor
- If classBody has explicit constructor: use it
- If not:
- Base class: use default constructor() {}
- Derived class: use default constructor(...args) { super(...args) }
- Create function object F via OrdinaryFunctionCreate
- Set F.prototype = proto
- Set proto.constructor = F (non-enumerable)
- If extends present:
- F.[[Prototype]] = superclass (constructor inheritance)
- F.[[ConstructorKind]] = 'derived'
- Otherwise:
- F.[[Prototype]] = Function.prototype
- F.[[ConstructorKind]] = 'base'
Step 5: Process each ClassElement
For each element in classBody:
- Instance methods: PropertyDefinitionEvaluation โ defined on proto (enumerable: false)
- Static methods: PropertyDefinitionEvaluation โ defined on F (enumerable: false)
- Private methods: registered in PrivateEnvironment
- Instance fields (including private): collected into fields list
- Static fields: evaluated and defined on F immediately
Step 6: Install private methods
- Associate each private method's PrivateName with the method in PrivateEnvironment
Step 7: Initialize static fields
- Evaluate each static field initializer in declaration order
Step 8: Return constructor F
How Private Fields Are Implemented
Private fields are not prototype properties or ordinary properties. They are implemented through PrivateName + [[PrivateElements]]:
Private Field Implementation Structure:
ClassEnvironment (created when class is evaluated):
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ PrivateEnvironmentRecord โ
โ #name โ PrivateName { [[Description]]: '#name' } โ
โ #count โ PrivateName { [[Description]]: '#count' } โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ when each instance is created (new ClassName())
โผ
Instance object (OrdinaryObject):
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ [[PrivateElements]] โ
โ [ { [[Key]]: PrivateName(#name), โ
โ [[Kind]]: 'field', โ
โ [[Value]]: 'Rex' }, โ
โ { [[Key]]: PrivateName(#count), โ
โ [[Kind]]: 'field', โ
โ [[Value]]: 0 } ] โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Accessing obj.#name:
1. Linear search in obj.[[PrivateElements]] for [[Key]] === PrivateName(#name)
2. If found: return [[Value]]
3. If not found: throw TypeError
"Cannot read private member #name from an object whose class did not declare it"
class Dog {
#name
constructor(name) { this.#name = name }
static isInstance(obj) {
return #name in obj // detect private field via 'in' operator (ES2022)
}
}
class Cat {
#name
constructor(name) { this.#name = name }
}
const dog = new Dog('Rex')
const cat = new Cat('Whiskers')
console.log(Dog.isInstance(dog)) // true
console.log(Dog.isInstance(cat)) // false โ Cat's #name and Dog's #name are different PrivateName objects
Where Private Methods Are Stored
Private methods differ from public methods: public methods are defined on the prototype (shared by all instances), while private method entries exist in each instance's [[PrivateElements]] (though the function object itself is shared):
Public Method vs Private Method Storage:
Public method speak():
Dog.prototype.speak = function() { ... }
All instances share the same function object via prototype chain
Memory: O(1), independent of instance count
Private method #speak():
Each instance's [[PrivateElements]] contains one entry:
{ [[Key]]: PrivateName(#speak), [[Kind]]: 'method', [[Value]]: <function> }
Although [[Value]] points to the same function object,
each instance carries its own PrivateElement entry
Memory: O(n), proportional to instance count (each entry has overhead)
class WithPrivateMethod {
#secret() { return 42 }
publicCall() { return this.#secret() }
}
const obj = new WithPrivateMethod()
console.log(obj.publicCall()) // 42
console.log(obj['#secret']) // undefined (not accessible this way)
// obj.#secret() // SyntaxError (#secret not in current lexical scope)
The TDZ of class Declarations
class declarations, like let/const, have a Temporal Dead Zone โ the declaration is hoisted but cannot be accessed until the declaration statement is executed:
class Declaration Hoisting and TDZ:
Before execution:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Variable Environment โ
โ Animal โ <uninitialized> (TDZ) โ
โ Dog โ <uninitialized> (TDZ) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
After executing class Animal { ... }:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Variable Environment โ
โ Animal โ [Animal function object] โ
โ Dog โ <uninitialized> (TDZ) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// class declarations have TDZ โ cannot use before declaration
new MyClass() // ReferenceError: Cannot access 'MyClass' before initialization
class MyClass {}
// function declarations are fully hoisted โ can use before declaration
new MyFunc() // works fine
function MyFunc() {}
๐บ Level 3 ยท What the Spec Says
15.7 Class Definitions
Spec 15.7 defines the evaluation of class-related syntax. Class declaration runtime semantics reference 15.7.14 ClassDefinitionEvaluation. Key algorithm nodes:
ClassElement Classification (15.7.1):
ClassElement types:
MethodDefinition โ instance method
static MethodDefinition โ static method
FieldDefinition ; โ instance field
static FieldDefinition ; โ static field
ClassStaticBlock โ static initialization block (ES2022)
; โ empty element (ignored)
When Class Fields Are Initialized:
Instance fields (#field = expr, field = expr) are not initialized during ClassDefinitionEvaluation. They are collected as initializers (InitializeInstanceElements) and executed on each new call during [[Construct]]:
Extra steps in [[Construct]] related to classes:
1. Create new object thisArgument
2. Call InitializeInstanceElements(thisArgument, F):
a. Install private methods: for each method in F.[[PrivateMethods]],
add a PrivateElement entry to thisArgument.[[PrivateElements]]
b. Execute field initializers: for each field in declaration order
- Private field #f = expr โ add PrivateName โ value to [[PrivateElements]]
- Public field f = expr โ DefinePropertyOrThrow(thisArgument, 'f', ...)
3. Execute constructor body
PrivateEnvironment Records (Spec 9.2)
PrivateEnvironmentRecord is a new type of environment record (introduced in ES2022) dedicated to managing the scope of private names:
PrivateEnvironmentRecord structure:
{
[[OuterPrivateEnvironment]]: outer PrivateEnvironmentRecord or null,
[[Names]]: list of private names (PrivateName objects)
}
PrivateName object:
{
[[Description]]: '#fieldName' (used in error messages)
}
Note: PrivateName is an object reference โ identity comparison (===) checks
reference equality. Dog's #name and Cat's #name are completely different
PrivateName objects, even if their description strings are identical.
This guarantees cross-class access security.
[[PrivateElements]] Storage and Access
Each object instance's [[PrivateElements]] is a List where each entry is a PrivateElement Record:
PrivateElement Record:
{
[[Key]]: PrivateName (a reference, not a string)
[[Kind]]: 'field' | 'method' | 'accessor'
[[Value]]: field value / method function object
(accessor kind has [[Get]] and [[Set]] instead of [[Value]])
}
The private field access operation PrivateGet(P, O) (spec 7.3.26):
PrivateGet(P, O):
// P is a PrivateName, O is an object
1. entry = PrivateElementFind(O, P)
// linear search in O.[[PrivateElements]] for [[Key]] === P
2. If entry is undefined:
throw TypeError ("Cannot read private member of an object
whose class did not declare it")
3. If entry.[[Kind]] === 'field':
return entry.[[Value]]
4. If entry.[[Kind]] === 'method':
return entry.[[Value]] (return the method function directly)
5. If entry.[[Kind]] === 'accessor':
if entry.[[Get]] is undefined: throw TypeError
return Call(entry.[[Get]], O)
The private field write operation PrivateSet(P, O, value) (spec 7.3.27):
PrivateSet(P, O, value):
1. entry = PrivateElementFind(O, P)
2. If entry is undefined: throw TypeError
3. If entry.[[Kind]] === 'field':
entry.[[Value]] = value
4. If entry.[[Kind]] === 'method':
throw TypeError (private methods cannot be assigned)
5. If entry.[[Kind]] === 'accessor':
if entry.[[Set]] is undefined: throw TypeError
Call(entry.[[Set]], O, [value])
The in Operator for Private Fields (Spec 13.10.1)
ES2022 introduced private field in detection (Ergonomic brand checks):
HasPrivateName(O, P):
// Implementation of `#field in obj`
1. entry = PrivateElementFind(O, P)
2. If entry is not undefined: return true
3. return false
This is more precise than instanceof: instanceof checks the prototype chain and can fail with multiple Realms or a modified Symbol.hasInstance; #field in obj directly checks [[PrivateElements]] and is unaffected by the prototype chain.
๐ Level 4 ยท Edge Cases and Traps
Trap 1: The this Initialization Rule in Subclass Constructors
A subclass's ([[ConstructorKind]] === 'derived') this is created by the parent class's [[Construct]], not the subclass. Before super() returns, this is in an "uninitialized" state:
class Base {
constructor() {
this.baseField = 1
}
}
class Derived extends Base {
#privateField
constructor() {
// accessing this here throws ReferenceError
// console.log(this) // ReferenceError
super()
// after super() returns, this has been initialized by Base's constructor
// and InitializeInstanceElements has already installed private fields
console.log(this.baseField) // 1
this.#privateField = 42 // private field is now accessible
}
}
new Derived()
If a subclass omits the constructor entirely, the default constructor(...args) { super(...args) } is used, forwarding all arguments automatically.
Trap 2: Static Private Fields Are Inaccessible Even to Subclasses
class Parent {
static #secret = 'parent secret'
static getSecret() {
return Parent.#secret // correct: within Parent's lexical scope
}
}
class Child extends Parent {
static getChildSecret() {
// return Child.#secret // SyntaxError: #secret not declared in Child
// Static private fields are NOT inherited!
return Parent.getSecret() // must access via parent's public method
}
}
console.log(Parent.getSecret()) // 'parent secret'
console.log(Child.getChildSecret()) // 'parent secret'
// console.log(Child.#secret) // SyntaxError
Trap 3: #field in obj Is a More Reliable Instance Check Than instanceof
class MyClass {
#brand // private field used as a brand check
constructor() {
this.#brand = true
}
static isInstance(obj) {
return #brand in obj // if the object has this private field, it's a MyClass instance
}
}
// Problem with instanceof: it can be faked
class FakeClass {
static [Symbol.hasInstance](obj) { return true } // spoofing instanceof
}
console.log({} instanceof FakeClass) // true โ false positive!
// Cross-realm: instanceof fails
// e.g., arrays from an iframe:
// [] instanceof Array // false (different Realm's Array is a different constructor)
// #brand in obj advantages:
const obj = new MyClass()
console.log(MyClass.isInstance(obj)) // true
console.log(MyClass.isInstance({})) // false
console.log(MyClass.isInstance(null)) // false (doesn't throw, returns false)
Trap 4: Class Methods Are Non-Enumerable; Object Literal Methods Are Enumerable
This difference causes surprises during serialization and for...in traversal:
class Foo {
bar() {}
}
const obj = {
bar() {}
}
// Check enumerability
const classDescriptor = Object.getOwnPropertyDescriptor(Foo.prototype, 'bar')
console.log(classDescriptor.enumerable) // false
const objDescriptor = Object.getOwnPropertyDescriptor(obj, 'bar')
console.log(objDescriptor.enumerable) // true
// Practical impact: for...in does not traverse class methods
for (const key in new Foo()) {
console.log(key) // no output (bar is non-enumerable)
}
for (const key in obj) {
console.log(key) // 'bar' (enumerable)
}
Trap 5: Private Method Memory Overhead Scales with Instance Count
class PublicMethod {
method() { return 42 }
}
class PrivateMethod {
#method() { return 42 }
call() { return this.#method() }
}
// Create 10,000 instances
const publicInstances = Array.from({ length: 10000 }, () => new PublicMethod())
const privateInstances = Array.from({ length: 10000 }, () => new PrivateMethod())
// Public method: all instances share PublicMethod.prototype.method โ 1 function object
// Private method: each instance has one PrivateElement entry in [[PrivateElements]]
// Although [[Value]] points to the same function object,
// 10,000 PrivateElement records themselves carry memory overhead
// In memory-sensitive scenarios, weigh the trade-off carefully
// Consider WeakMap-based privacy (the old approach) for high-volume instances
// WeakMap pattern (private-like, no per-instance overhead)
const _method = new WeakMap()
class WeakMapPrivate {
constructor() {
_method.set(this, function() { return 42 })
}
call() { return _method.get(this)() }
}
Chapter Summary
ClassDefinitionEvaluationcreates two prototype chains: the instance chain (instance โ ClassName.prototype โ ParentClass.prototype) and the constructor chain (ClassName โ ParentClass), the latter enabling static method inheritance.- Private fields are implemented via PrivateName +
[[PrivateElements]]. PrivateName is an object reference (not a string); same-named private fields from different classes are completely different PrivateName objects and cannot access each other. - A subclass's
thisis created bysuper()calling the parent's[[Construct]]. Accessingthisbeforesuper()throwsReferenceError. Field initializers (including private fields) execute aftersuper()returns, before the rest of the constructor body. - Static private fields (
static #field) are only visible within the lexical scope of the declaring class. Subclasses cannot inherit or access them, even through instances. - Each instance of a class with private methods carries an independent PrivateElement entry in
[[PrivateElements]], making memory cost O(n) with instance count. Evaluate the impact in scenarios with high-frequency instance creation.